mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-11 16:01:20 +00:00
Merge branch '427-small-changes-related-to-the-preview-of-templates-on-the-saas-website' into 'develop'
Resolve "Small changes related to the preview of templates on the SaaS website" Closes #427 See merge request bramw/baserow!231
This commit is contained in:
commit
1ac5931460
36 changed files with 871 additions and 555 deletions
backend
src/baserow
tests/baserow
web-frontend
modules
core
database/components
|
@ -6,6 +6,7 @@ from django.contrib.auth.models import update_last_login
|
|||
|
||||
from baserow.api.groups.invitations.serializers import UserGroupInvitationSerializer
|
||||
from baserow.core.user.utils import normalize_email_address
|
||||
from baserow.core.models import Template
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
@ -37,6 +38,14 @@ class RegisterSerializer(serializers.Serializer):
|
|||
help_text="If provided and valid, the user accepts the group invitation and "
|
||||
"will have access to the group after signing up.",
|
||||
)
|
||||
template_id = serializers.PrimaryKeyRelatedField(
|
||||
required=False,
|
||||
default=None,
|
||||
queryset=Template.objects.all(),
|
||||
help_text="The id of the template that must be installed after creating the "
|
||||
"account. This only works if the `group_invitation_token` param is not "
|
||||
"provided.",
|
||||
)
|
||||
|
||||
|
||||
class SendResetPasswordEmailBodyValidationSerializer(serializers.Serializer):
|
||||
|
|
|
@ -31,7 +31,7 @@ from baserow.core.exceptions import (
|
|||
GroupInvitationEmailMismatch,
|
||||
GroupInvitationDoesNotExist,
|
||||
)
|
||||
from baserow.core.models import GroupInvitation
|
||||
from baserow.core.models import GroupInvitation, Template
|
||||
from baserow.core.user.handler import UserHandler
|
||||
from baserow.core.user.exceptions import (
|
||||
UserAlreadyExist,
|
||||
|
@ -173,11 +173,18 @@ class UserView(APIView):
|
|||
def post(self, request, data):
|
||||
"""Registers a new user."""
|
||||
|
||||
template = (
|
||||
Template.objects.get(pk=data["template_id"])
|
||||
if data["template_id"]
|
||||
else None
|
||||
)
|
||||
|
||||
user = UserHandler().create_user(
|
||||
name=data["name"],
|
||||
email=data["email"],
|
||||
password=data["password"],
|
||||
group_invitation_token=data.get("group_invitation_token"),
|
||||
template=template,
|
||||
)
|
||||
|
||||
response = {"user": UserSerializer(user).data}
|
||||
|
|
|
@ -18,7 +18,7 @@ from .fields.field_types import (
|
|||
class DatabasePlugin(Plugin):
|
||||
type = "database"
|
||||
|
||||
def user_created(self, user, group, group_invitation):
|
||||
def user_created(self, user, group, group_invitation, template):
|
||||
"""
|
||||
This method is called when a new user is created. We are going to create a
|
||||
database, table, view, fields and some rows here as an example for the user.
|
||||
|
@ -27,7 +27,7 @@ class DatabasePlugin(Plugin):
|
|||
# If the user created an account in combination with a group invitation we
|
||||
# don't want to create the initial data in the group because data should
|
||||
# already exist.
|
||||
if group_invitation:
|
||||
if group_invitation or template:
|
||||
return
|
||||
|
||||
core_handler = CoreHandler()
|
||||
|
|
|
@ -71,7 +71,7 @@ class Plugin(APIUrlsInstanceMixin, Instance):
|
|||
|
||||
return []
|
||||
|
||||
def user_created(self, user, group, group_invitation):
|
||||
def user_created(self, user, group, group_invitation, template):
|
||||
"""
|
||||
A hook that is called after a new user has been created. This is the place to
|
||||
create some data the user can start with. A group has already been created
|
||||
|
@ -84,6 +84,9 @@ class Plugin(APIUrlsInstanceMixin, Instance):
|
|||
:param group_invitation: Is provided if the user has signed up using a valid
|
||||
group invitation token.
|
||||
:type group_invitation: GroupInvitation
|
||||
:param template: The template that is installed right after creating the
|
||||
account. Is `None` if the template was not created.
|
||||
:type template: Template or None
|
||||
"""
|
||||
|
||||
def user_signed_in(self, user):
|
||||
|
|
|
@ -56,7 +56,9 @@ class UserHandler:
|
|||
except User.DoesNotExist:
|
||||
raise UserNotFound("The user with the provided parameters is not found.")
|
||||
|
||||
def create_user(self, name, email, password, group_invitation_token=None):
|
||||
def create_user(
|
||||
self, name, email, password, group_invitation_token=None, template=None
|
||||
):
|
||||
"""
|
||||
Creates a new user with the provided information and creates a new group and
|
||||
application for him. If the optional group invitation is provided then the user
|
||||
|
@ -71,6 +73,9 @@ class UserHandler:
|
|||
:param group_invitation_token: If provided and valid, the invitation will be
|
||||
accepted and and initial group will not be created.
|
||||
:type group_invitation_token: str
|
||||
:param template: If provided, that template will be installed into the newly
|
||||
created group.
|
||||
:type template: Template
|
||||
:raises: UserAlreadyExist: When a user with the provided username (email)
|
||||
already exists.
|
||||
:raises GroupInvitationEmailMismatch: If the group invitation email does not
|
||||
|
@ -80,7 +85,9 @@ class UserHandler:
|
|||
:rtype: User
|
||||
"""
|
||||
|
||||
if not CoreHandler().get_settings().allow_new_signups:
|
||||
core_handler = CoreHandler()
|
||||
|
||||
if not core_handler.get_settings().allow_new_signups:
|
||||
raise DisabledSignupError("Sign up is disabled.")
|
||||
|
||||
email = normalize_email_address(email)
|
||||
|
@ -88,7 +95,6 @@ class UserHandler:
|
|||
if User.objects.filter(Q(email=email) | Q(username=email)).exists():
|
||||
raise UserAlreadyExist(f"A user with username {email} already exists.")
|
||||
|
||||
core_handler = CoreHandler()
|
||||
group_invitation = None
|
||||
group_user = None
|
||||
|
||||
|
@ -120,9 +126,12 @@ class UserHandler:
|
|||
if not group_user:
|
||||
group_user = core_handler.create_group(user=user, name=f"{name}'s group")
|
||||
|
||||
if not group_invitation_token and template:
|
||||
core_handler.install_template(user, group_user.group, template)
|
||||
|
||||
# Call the user_created method for each plugin that is un the registry.
|
||||
for plugin in plugin_registry.registry.values():
|
||||
plugin.user_created(user, group_user.group, group_invitation)
|
||||
plugin.user_created(user, group_user.group, group_invitation, template)
|
||||
|
||||
return user
|
||||
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import os
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.shortcuts import reverse
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST
|
||||
from django.conf import settings
|
||||
|
||||
from baserow.contrib.database.models import Database, Table
|
||||
from baserow.core.handler import CoreHandler
|
||||
|
@ -99,7 +102,7 @@ def test_create_user_with_invitation(data_fixture, client):
|
|||
},
|
||||
format="json",
|
||||
)
|
||||
assert response_failed.status_code == 400
|
||||
assert response_failed.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_failed.json()["error"] == "BAD_TOKEN_SIGNATURE"
|
||||
|
||||
response_failed = client.post(
|
||||
|
@ -112,7 +115,7 @@ def test_create_user_with_invitation(data_fixture, client):
|
|||
},
|
||||
format="json",
|
||||
)
|
||||
assert response_failed.status_code == 404
|
||||
assert response_failed.status_code == HTTP_404_NOT_FOUND
|
||||
assert response_failed.json()["error"] == "ERROR_GROUP_INVITATION_DOES_NOT_EXIST"
|
||||
|
||||
response_failed = client.post(
|
||||
|
@ -125,7 +128,7 @@ def test_create_user_with_invitation(data_fixture, client):
|
|||
},
|
||||
format="json",
|
||||
)
|
||||
assert response_failed.status_code == 400
|
||||
assert response_failed.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_failed.json()["error"] == "ERROR_GROUP_INVITATION_EMAIL_MISMATCH"
|
||||
assert User.objects.all().count() == 1
|
||||
|
||||
|
@ -139,7 +142,7 @@ def test_create_user_with_invitation(data_fixture, client):
|
|||
},
|
||||
format="json",
|
||||
)
|
||||
assert response_failed.status_code == 200
|
||||
assert response_failed.status_code == HTTP_200_OK
|
||||
assert User.objects.all().count() == 2
|
||||
assert Group.objects.all().count() == 1
|
||||
assert Group.objects.all().first().id == invitation.group_id
|
||||
|
@ -148,6 +151,65 @@ def test_create_user_with_invitation(data_fixture, client):
|
|||
assert Table.objects.all().count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_user_with_template(data_fixture, client):
|
||||
old_templates = settings.APPLICATION_TEMPLATES_DIR
|
||||
settings.APPLICATION_TEMPLATES_DIR = os.path.join(
|
||||
settings.BASE_DIR, "../../../tests/templates"
|
||||
)
|
||||
template = data_fixture.create_template(slug="example-template")
|
||||
|
||||
response = client.post(
|
||||
reverse("api:user:index"),
|
||||
{
|
||||
"name": "Test1",
|
||||
"email": "test0@test.nl",
|
||||
"password": "test12",
|
||||
"template_id": -1,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert response_json["detail"]["template_id"][0]["code"] == "does_not_exist"
|
||||
|
||||
response = client.post(
|
||||
reverse("api:user:index"),
|
||||
{
|
||||
"name": "Test1",
|
||||
"email": "test0@test.nl",
|
||||
"password": "test12",
|
||||
"template_id": "random",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert response_json["detail"]["template_id"][0]["code"] == "incorrect_type"
|
||||
|
||||
response = client.post(
|
||||
reverse("api:user:index"),
|
||||
{
|
||||
"name": "Test1",
|
||||
"email": "test0@test.nl",
|
||||
"password": "test12",
|
||||
"template_id": template.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert Group.objects.all().count() == 2
|
||||
assert GroupUser.objects.all().count() == 1
|
||||
# We expect the example template to be installed
|
||||
assert Database.objects.all().count() == 1
|
||||
assert Database.objects.all().first().name == "Event marketing"
|
||||
assert Table.objects.all().count() == 2
|
||||
|
||||
settings.APPLICATION_TEMPLATES_DIR = old_templates
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_send_reset_password_email(data_fixture, client, mailoutbox):
|
||||
data_fixture.create_user(email="test@localhost.nl")
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import os
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from freezegun import freeze_time
|
||||
from itsdangerous.exc import SignatureExpired, BadSignature
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
|
||||
from baserow.contrib.database.models import (
|
||||
Database,
|
||||
Table,
|
||||
|
@ -104,7 +106,7 @@ def test_create_user(data_fixture):
|
|||
assert model_2_results[1].order == Decimal("2.00000000000000000000")
|
||||
assert model_2_results[2].order == Decimal("3.00000000000000000000")
|
||||
|
||||
plugin_mock.user_created.assert_called_with(user, group, None)
|
||||
plugin_mock.user_created.assert_called_with(user, group, None, None)
|
||||
|
||||
with pytest.raises(UserAlreadyExist):
|
||||
user_handler.create_user("Test1", "test@test.nl", "password")
|
||||
|
@ -169,6 +171,26 @@ def test_create_user_with_invitation(data_fixture):
|
|||
assert Table.objects.all().count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_user_with_template(data_fixture):
|
||||
old_templates = settings.APPLICATION_TEMPLATES_DIR
|
||||
settings.APPLICATION_TEMPLATES_DIR = os.path.join(
|
||||
settings.BASE_DIR, "../../../tests/templates"
|
||||
)
|
||||
template = data_fixture.create_template(slug="example-template")
|
||||
user_handler = UserHandler()
|
||||
user_handler.create_user("Test1", "test0@test.nl", "password", template=template)
|
||||
|
||||
assert Group.objects.all().count() == 2
|
||||
assert GroupUser.objects.all().count() == 1
|
||||
# We expect the example template to be installed
|
||||
assert Database.objects.all().count() == 1
|
||||
assert Database.objects.all().first().name == "Event marketing"
|
||||
assert Table.objects.all().count() == 2
|
||||
|
||||
settings.APPLICATION_TEMPLATES_DIR = old_templates
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_send_reset_password_email(data_fixture, mailoutbox):
|
||||
user = data_fixture.create_user(email="test@localhost")
|
||||
|
|
|
@ -6,6 +6,9 @@
|
|||
* Switch to using a celery based email backend by default.
|
||||
* Added `--add-columns` flag to the `fill_table` management command. It creates all the
|
||||
field types before filling the table with random data.
|
||||
* Make the view header more compact when the content doesn't fit anymore.
|
||||
* Allow providing a `template_id` when registering a new account, which will install
|
||||
that template instead of the default database.
|
||||
|
||||
## Released (2021-04-08)
|
||||
|
||||
|
|
|
@ -75,16 +75,17 @@
|
|||
.header__filter-item {
|
||||
@extend %first-last-no-margin;
|
||||
|
||||
margin-left: 12px;
|
||||
margin-left: 10px;
|
||||
|
||||
&.header__filter-item--right {
|
||||
margin-left: auto;
|
||||
margin-right: 12px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.header__filter-link {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
color: $color-primary-900;
|
||||
padding: 0 10px;
|
||||
|
@ -115,10 +116,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
.header--overflow .header__filter-name {
|
||||
&:not(.header__filter-name--forced) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.header__filter-name--forced {
|
||||
@extend %ellipsis;
|
||||
|
||||
max-width: 120px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.header__filter-icon {
|
||||
color: $color-primary-900;
|
||||
margin-right: 4px;
|
||||
|
||||
.header--overflow & {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&.header-filter-icon--view {
|
||||
color: $color-primary-500;
|
||||
font-size: 14px;
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
.modal__wrapper {
|
||||
@include absolute(0);
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow: auto;
|
||||
z-index: $z-index-modal;
|
||||
background-color: rgba($color-neutral-700, 0.16);
|
||||
|
@ -29,6 +32,10 @@
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
&.modal__box--small {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.box__title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
|
|
@ -66,12 +66,6 @@
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
.templates__category {
|
||||
&.templates__category--open {
|
||||
// Nothing
|
||||
}
|
||||
}
|
||||
|
||||
.templates__category-link {
|
||||
@extend %ellipsis;
|
||||
|
||||
|
|
|
@ -82,6 +82,10 @@
|
|||
user-select: initial !important;
|
||||
}
|
||||
|
||||
.prevent-scroll {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
|
|
|
@ -188,6 +188,10 @@ export default {
|
|||
target.classList.remove('forced-block')
|
||||
}
|
||||
|
||||
// Take into account that the document might be scrollable.
|
||||
verticalOffset += document.documentElement.scrollTop
|
||||
horizontalOffset += document.documentElement.scrollLeft
|
||||
|
||||
// Calculate if top, bottom, left and right positions are possible.
|
||||
const canTop = targetRect.top - contextRect.height - verticalOffset > 0
|
||||
const canBottom =
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
:class="{
|
||||
'modal__box--with-sidebar': sidebar,
|
||||
'modal__box--full-screen': fullScreen,
|
||||
'modal__box--small': small,
|
||||
}"
|
||||
>
|
||||
<a v-if="closeButton" class="modal__close" @click="hide()">
|
||||
|
@ -47,6 +48,11 @@ export default {
|
|||
default: false,
|
||||
required: false,
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
closeButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
|
|
163
web-frontend/modules/core/components/auth/AuthLogin.vue
Normal file
163
web-frontend/modules/core/components/auth/AuthLogin.vue
Normal file
|
@ -0,0 +1,163 @@
|
|||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="invitation !== null"
|
||||
class="alert alert--simple alert-primary alert--has-icon"
|
||||
>
|
||||
<div class="alert__icon">
|
||||
<i class="fas fa-exclamation"></i>
|
||||
</div>
|
||||
<div class="alert__title">Invitation</div>
|
||||
<p class="alert__content">
|
||||
<strong>{{ invitation.invited_by }}</strong> has invited you to join
|
||||
<strong>{{ invitation.group }}</strong
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
<Error :error="error"></Error>
|
||||
<form @submit.prevent="login">
|
||||
<div class="control">
|
||||
<label class="control__label">E-mail address</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
v-if="invitation !== null"
|
||||
ref="email"
|
||||
type="email"
|
||||
class="input input--large"
|
||||
disabled
|
||||
:value="credentials.email"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
ref="email"
|
||||
v-model="credentials.email"
|
||||
:class="{ 'input--error': $v.credentials.email.$error }"
|
||||
type="email"
|
||||
class="input input--large"
|
||||
@blur="$v.credentials.email.$touch()"
|
||||
/>
|
||||
<div v-if="$v.credentials.email.$error" class="error">
|
||||
Please enter a valid e-mail address.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="control__label">Password</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="password"
|
||||
v-model="credentials.password"
|
||||
:class="{ 'input--error': $v.credentials.password.$error }"
|
||||
type="password"
|
||||
class="input input--large"
|
||||
@blur="$v.credentials.password.$touch()"
|
||||
/>
|
||||
<div v-if="$v.credentials.password.$error" class="error">
|
||||
A password is required.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<slot></slot>
|
||||
<button
|
||||
:class="{ 'button--loading': loading }"
|
||||
class="button button--large"
|
||||
:disabled="loading"
|
||||
>
|
||||
Sign in
|
||||
<i class="fas fa-lock-open"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { required, email } from 'vuelidate/lib/validators'
|
||||
import error from '@baserow/modules/core/mixins/error'
|
||||
import GroupService from '@baserow/modules/core/services/group'
|
||||
|
||||
export default {
|
||||
name: 'AuthLogin',
|
||||
mixins: [error],
|
||||
props: {
|
||||
invitation: {
|
||||
required: false,
|
||||
validator: (prop) => typeof prop === 'object' || prop === null,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
credentials: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
if (this.invitation !== null) {
|
||||
this.credentials.email = this.invitation.email
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async login() {
|
||||
this.$v.$touch()
|
||||
if (this.$v.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.hideError()
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('auth/login', {
|
||||
email: this.credentials.email,
|
||||
password: this.credentials.password,
|
||||
})
|
||||
|
||||
// If there is an invitation we can immediately accept that one after the user
|
||||
// successfully signs in.
|
||||
if (
|
||||
this.invitation !== null &&
|
||||
this.invitation.email === this.credentials.email
|
||||
) {
|
||||
await GroupService(this.$client).acceptInvitation(this.invitation.id)
|
||||
}
|
||||
|
||||
this.$emit('success')
|
||||
} catch (error) {
|
||||
if (error.handler) {
|
||||
const response = error.handler.response
|
||||
// Because the API server does not yet respond with proper error codes we
|
||||
// manually have to add the error here.
|
||||
if (response && response.status === 400) {
|
||||
this.showError(
|
||||
'Incorrect credentials',
|
||||
'The provided e-mail address or password is ' + 'incorrect.'
|
||||
)
|
||||
this.credentials.password = ''
|
||||
this.$v.$reset()
|
||||
this.$refs.password.focus()
|
||||
} else {
|
||||
const message = error.handler.getMessage('login')
|
||||
this.showError(message)
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
error.handler.handled()
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
credentials: {
|
||||
email: { required, email },
|
||||
password: { required },
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
223
web-frontend/modules/core/components/auth/AuthRegister.vue
Normal file
223
web-frontend/modules/core/components/auth/AuthRegister.vue
Normal file
|
@ -0,0 +1,223 @@
|
|||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="invitation !== null"
|
||||
class="alert alert--simple alert--primary alert--has-icon"
|
||||
>
|
||||
<div class="alert__icon">
|
||||
<i class="fas fa-exclamation"></i>
|
||||
</div>
|
||||
<div class="alert__title">Invitation</div>
|
||||
<p class="alert__content">
|
||||
<strong>{{ invitation.invited_by }}</strong> has invited you to join
|
||||
<strong>{{ invitation.group }}</strong
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
<Error :error="error"></Error>
|
||||
<form @submit.prevent="register">
|
||||
<div class="control">
|
||||
<label class="control__label">E-mail address</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
v-if="invitation !== null"
|
||||
ref="email"
|
||||
type="email"
|
||||
class="input input--large"
|
||||
disabled
|
||||
:value="account.email"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
ref="email"
|
||||
v-model="account.email"
|
||||
:class="{ 'input--error': $v.account.email.$error }"
|
||||
type="text"
|
||||
class="input input--large"
|
||||
@blur="$v.account.email.$touch()"
|
||||
/>
|
||||
<div v-if="$v.account.email.$error" class="error">
|
||||
Please enter a valid e-mail address.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="control__label">Your name</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
v-model="account.name"
|
||||
:class="{ 'input--error': $v.account.name.$error }"
|
||||
type="text"
|
||||
class="input input--large"
|
||||
@blur="$v.account.name.$touch()"
|
||||
/>
|
||||
<div v-if="$v.account.name.$error" class="error">
|
||||
A minimum of two characters is required here.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="control__label">Password</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
v-model="account.password"
|
||||
:class="{ 'input--error': $v.account.password.$error }"
|
||||
type="password"
|
||||
class="input input--large"
|
||||
@blur="$v.account.password.$touch()"
|
||||
/>
|
||||
<div
|
||||
v-if="$v.account.password.$error && !$v.account.password.required"
|
||||
class="error"
|
||||
>
|
||||
A password is required.
|
||||
</div>
|
||||
<div
|
||||
v-if="$v.account.password.$error && !$v.account.password.maxLength"
|
||||
class="error"
|
||||
>
|
||||
A maximum of
|
||||
{{ $v.account.password.$params.maxLength.max }} characters is
|
||||
allowed here.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="control__label">Repeat password</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
v-model="account.passwordConfirm"
|
||||
:class="{ 'input--error': $v.account.passwordConfirm.$error }"
|
||||
type="password"
|
||||
class="input input--large"
|
||||
@blur="$v.account.passwordConfirm.$touch()"
|
||||
/>
|
||||
<div v-if="$v.account.passwordConfirm.$error" class="error">
|
||||
This field must match your password field.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<slot></slot>
|
||||
<button
|
||||
:class="{ 'button--loading': loading }"
|
||||
class="button button--large"
|
||||
:disabled="loading"
|
||||
>
|
||||
Sign up
|
||||
<i class="fas fa-user-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
email,
|
||||
maxLength,
|
||||
minLength,
|
||||
required,
|
||||
sameAs,
|
||||
} from 'vuelidate/lib/validators'
|
||||
import { ResponseErrorMessage } from '@baserow/modules/core/plugins/clientHandler'
|
||||
import error from '@baserow/modules/core/mixins/error'
|
||||
|
||||
export default {
|
||||
name: 'AuthRegister',
|
||||
mixins: [error],
|
||||
props: {
|
||||
invitation: {
|
||||
required: false,
|
||||
validator: (prop) => typeof prop === 'object' || prop === null,
|
||||
default: null,
|
||||
},
|
||||
template: {
|
||||
required: false,
|
||||
validator: (prop) => typeof prop === 'object' || prop === null,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
account: {
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
if (this.invitation !== null) {
|
||||
this.account.email = this.invitation.email
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async register() {
|
||||
this.$v.$touch()
|
||||
if (this.$v.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.hideError()
|
||||
|
||||
try {
|
||||
const values = {
|
||||
name: this.account.name,
|
||||
email: this.account.email,
|
||||
password: this.account.password,
|
||||
}
|
||||
|
||||
// If there is a valid invitation we can add the group invitation token to the
|
||||
// action parameters so that is can be passed along when signing up. That makes
|
||||
// the user accept the group invitation without creating a new group for the
|
||||
// user.
|
||||
if (this.invitation !== null) {
|
||||
values.groupInvitationToken = this.$route.query.groupInvitationToken
|
||||
}
|
||||
|
||||
// If a template is provided, we can add that id to the parameters so that the
|
||||
// template will be installed right while creating the account. This is going
|
||||
// to done instead of the default example template.
|
||||
if (this.template !== null) {
|
||||
values.templateId = this.template.id
|
||||
}
|
||||
|
||||
await this.$store.dispatch('auth/register', values)
|
||||
Object.values(this.$registry.getAll('plugin')).forEach((plugin) => {
|
||||
plugin.userCreated(this.account, this)
|
||||
})
|
||||
|
||||
this.$emit('success')
|
||||
} catch (error) {
|
||||
this.loading = false
|
||||
this.handleError(error, 'signup', {
|
||||
ERROR_EMAIL_ALREADY_EXISTS: new ResponseErrorMessage(
|
||||
'User already exists.',
|
||||
'A user with the provided e-mail address already exists.'
|
||||
),
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
account: {
|
||||
email: { required, email },
|
||||
name: {
|
||||
required,
|
||||
minLength: minLength(2),
|
||||
},
|
||||
password: {
|
||||
required,
|
||||
maxLength: maxLength(256),
|
||||
},
|
||||
passwordConfirm: {
|
||||
sameAsPassword: sameAs('password'),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -122,7 +122,7 @@
|
|||
<GroupsContext ref="groupSelect"></GroupsContext>
|
||||
</div>
|
||||
</li>
|
||||
<li class="tree__item">
|
||||
<li v-if="selectedGroup.permissions === 'ADMIN'" class="tree__item">
|
||||
<div class="tree__action">
|
||||
<a class="tree__link" @click="$refs.groupMembersModal.show()">
|
||||
<i class="tree__icon tree__icon--type fas fa-users"></i>
|
||||
|
|
|
@ -1,110 +0,0 @@
|
|||
<template>
|
||||
<div class="templates__body">
|
||||
<template v-if="template !== null">
|
||||
<div v-if="loading" class="loading-absolute-center"></div>
|
||||
<div v-else class="layout">
|
||||
<div class="layout__col-1">
|
||||
<TemplateSidebar
|
||||
:template="template"
|
||||
:applications="applications"
|
||||
:page="page"
|
||||
@selected-page="selectPage"
|
||||
></TemplateSidebar>
|
||||
</div>
|
||||
<div class="layout__col-2">
|
||||
<component
|
||||
:is="pageComponent"
|
||||
v-if="page !== null"
|
||||
:page-value="page.value"
|
||||
></component>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
import ApplicationService from '@baserow/modules/core/services/application'
|
||||
import { populateApplication } from '@baserow/modules/core/store/application'
|
||||
import TemplateSidebar from '@baserow/modules/core/components/template/TemplateSidebar'
|
||||
|
||||
export default {
|
||||
name: 'TemplateBody',
|
||||
components: { TemplateSidebar },
|
||||
props: {
|
||||
template: {
|
||||
required: true,
|
||||
validator: (prop) => typeof prop === 'object' || prop === null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
applications: [],
|
||||
page: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
pageComponent() {
|
||||
if (this.page !== null) {
|
||||
return this.$registry
|
||||
.get('application', this.page.application)
|
||||
.getTemplatesPageComponent()
|
||||
}
|
||||
return null
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
template(value) {
|
||||
if (value === null) {
|
||||
this.loading = false
|
||||
this.applications = []
|
||||
this.page = null
|
||||
return
|
||||
}
|
||||
this.fetchApplications(value)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async fetchApplications(template) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const { data } = await ApplicationService(this.$client).fetchAll(
|
||||
template.group_id
|
||||
)
|
||||
data.forEach((application) => {
|
||||
populateApplication(application, this.$registry)
|
||||
})
|
||||
this.applications = data
|
||||
|
||||
// Check if there is an application that can give us an initial page. The
|
||||
// database application type would for example return the first table as page.
|
||||
for (let i = 0; i < this.applications.length; i++) {
|
||||
const application = this.applications[i]
|
||||
const pageValue = this.$registry
|
||||
.get('application', application.type)
|
||||
.getTemplatePage(application)
|
||||
if (pageValue !== null) {
|
||||
application._.selected = true
|
||||
this.selectPage({
|
||||
application: application.type,
|
||||
value: pageValue,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.applications = []
|
||||
notifyIf(error, 'templates')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
selectPage({ application, value }) {
|
||||
this.page = { application, value }
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -52,53 +52,16 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { escapeRegExp } from '@baserow/modules/core/utils/string'
|
||||
import { clone } from '@baserow/modules/core/utils/object'
|
||||
import templateCategories from '@baserow/modules/core/mixins/templateCategories'
|
||||
|
||||
export default {
|
||||
name: 'TemplateCategories',
|
||||
mixins: [templateCategories],
|
||||
props: {
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
selectedTemplate: {
|
||||
required: true,
|
||||
validator: (prop) => typeof prop === 'object' || prop === null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
search: '',
|
||||
selectedCategoryId: -1,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* Returns the categories and templates that match the search query.
|
||||
*/
|
||||
matchingCategories() {
|
||||
if (this.search === '') {
|
||||
return this.categories
|
||||
}
|
||||
|
||||
return clone(this.categories)
|
||||
.map((category) => {
|
||||
category.templates = category.templates.filter((template) => {
|
||||
const keywords = template.keywords.split(',')
|
||||
keywords.push(template.name)
|
||||
const regex = new RegExp('(' + escapeRegExp(this.search) + ')', 'i')
|
||||
return keywords.some((value) => value.match(regex))
|
||||
})
|
||||
return category
|
||||
})
|
||||
.filter((category) => category.templates.length > 0)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
selectCategory(id) {
|
||||
this.selectedCategoryId = this.selectedCategoryId === id ? -1 : id
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -61,7 +61,7 @@ export default {
|
|||
this.$store.dispatch('application/forceCreate', application)
|
||||
})
|
||||
if (data.length > 0) {
|
||||
// If there are applications we want to select the first one right away.
|
||||
// If there are applications, we want to select the first one right away.
|
||||
const application = this.$store.getters['application/get'](data[0].id)
|
||||
const type = this.$registry.get('application', application.type)
|
||||
type.select(application, this)
|
||||
|
|
|
@ -18,7 +18,10 @@
|
|||
<i class="fas fa-times"></i>
|
||||
</a>
|
||||
</TemplateCategories>
|
||||
<TemplateBody :template="selectedTemplate"></TemplateBody>
|
||||
<TemplatePreview
|
||||
:template="selectedTemplate"
|
||||
class="templates__body"
|
||||
></TemplatePreview>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
@ -30,11 +33,11 @@ import { notifyIf } from '@baserow/modules/core/utils/error'
|
|||
|
||||
import TemplateHeader from '@baserow/modules/core/components/template/TemplateHeader'
|
||||
import TemplateCategories from '@baserow/modules/core/components/template/TemplateCategories'
|
||||
import TemplateBody from '@baserow/modules/core/components/template/TemplateBody'
|
||||
import TemplatePreview from '@baserow/modules/core/components/template/TemplatePreview'
|
||||
|
||||
export default {
|
||||
name: 'TemplateModal',
|
||||
components: { TemplateHeader, TemplateCategories, TemplateBody },
|
||||
components: { TemplateHeader, TemplateCategories, TemplatePreview },
|
||||
mixins: [modal],
|
||||
props: {
|
||||
group: {
|
||||
|
|
|
@ -1,9 +1,127 @@
|
|||
<template>
|
||||
<div>@TODO</div>
|
||||
<div>
|
||||
<template v-if="template !== null">
|
||||
<div v-if="loading" class="loading-absolute-center"></div>
|
||||
<div v-else class="layout" :class="{ 'layout--collapsed': collapsed }">
|
||||
<div class="layout__col-1">
|
||||
<TemplateSidebar
|
||||
:template="template"
|
||||
:applications="applications"
|
||||
:page="page"
|
||||
:collapsed="collapsed"
|
||||
@selected-page="selectPage"
|
||||
@collapse-toggled="collapsed = !collapsed"
|
||||
></TemplateSidebar>
|
||||
</div>
|
||||
<div class="layout__col-2">
|
||||
<component
|
||||
:is="pageComponent"
|
||||
v-if="page !== null"
|
||||
:page-value="page.value"
|
||||
></component>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
import ApplicationService from '@baserow/modules/core/services/application'
|
||||
import { populateApplication } from '@baserow/modules/core/store/application'
|
||||
import TemplateSidebar from '@baserow/modules/core/components/template/TemplateSidebar'
|
||||
|
||||
export default {
|
||||
name: 'TemplatesBody',
|
||||
name: 'TemplatePreview',
|
||||
components: { TemplateSidebar },
|
||||
props: {
|
||||
template: {
|
||||
required: true,
|
||||
validator: (prop) => typeof prop === 'object' || prop === null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
applications: [],
|
||||
page: null,
|
||||
collapsed: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
pageComponent() {
|
||||
if (this.page !== null) {
|
||||
return this.$registry
|
||||
.get('application', this.page.application)
|
||||
.getTemplatesPageComponent()
|
||||
}
|
||||
return null
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
template(value) {
|
||||
if (value === null) {
|
||||
this.loading = false
|
||||
this.applications = []
|
||||
this.page = null
|
||||
return
|
||||
}
|
||||
this.fetchApplications(value)
|
||||
},
|
||||
},
|
||||
created() {
|
||||
if (this.template !== null) {
|
||||
this.fetchApplications(this.template)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
// If the window doesn't have that much space, we want to start with the sidebar
|
||||
// collapsed.
|
||||
if (window.outerWidth < 640) {
|
||||
this.collapsed = true
|
||||
}
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
async fetchApplications(template) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const { data } = await ApplicationService(this.$client).fetchAll(
|
||||
template.group_id
|
||||
)
|
||||
data.forEach((application) => {
|
||||
populateApplication(application, this.$registry)
|
||||
})
|
||||
this.applications = data
|
||||
|
||||
// Check if there is an application that can give us an initial page. The
|
||||
// database application type would for example return the first table as page.
|
||||
for (let i = 0; i < this.applications.length; i++) {
|
||||
const application = this.applications[i]
|
||||
const pageValue = this.$registry
|
||||
.get('application', application.type)
|
||||
.getTemplatePage(application)
|
||||
if (pageValue !== null) {
|
||||
application._.selected = true
|
||||
this.selectPage({
|
||||
application: application.type,
|
||||
value: pageValue,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.applications = []
|
||||
notifyIf(error, 'templates')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
selectPage({ application, value }) {
|
||||
this.page = { application, value }
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar__nav">
|
||||
<div v-show="!collapsed" class="sidebar__nav">
|
||||
<ul class="tree">
|
||||
<li class="tree__item margin-top-2">
|
||||
<div class="tree__link tree__link--group">
|
||||
|
@ -18,6 +18,24 @@
|
|||
></component>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="sidebar__foot">
|
||||
<div class="sidebar__logo">
|
||||
<img
|
||||
height="14"
|
||||
src="@baserow/modules/core/static/img/logo.svg"
|
||||
alt="Baserow logo"
|
||||
/>
|
||||
</div>
|
||||
<a class="sidebar__collapse-link" @click="$emit('collapse-toggled')">
|
||||
<i
|
||||
class="fas"
|
||||
:class="{
|
||||
'fa-angle-double-right': collapsed,
|
||||
'fa-angle-double-left': !collapsed,
|
||||
}"
|
||||
></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -37,6 +55,10 @@ export default {
|
|||
required: true,
|
||||
validator: (prop) => typeof prop === 'object' || prop === null,
|
||||
},
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getApplicationComponent(application) {
|
||||
|
|
|
@ -32,6 +32,7 @@ export default {
|
|||
this.open = true
|
||||
this.$emit('show')
|
||||
window.addEventListener('keyup', this.keyup)
|
||||
document.body.classList.add('prevent-scroll')
|
||||
},
|
||||
/**
|
||||
* Hide the modal.
|
||||
|
@ -52,6 +53,7 @@ export default {
|
|||
}
|
||||
|
||||
window.removeEventListener('keyup', this.keyup)
|
||||
document.body.classList.remove('prevent-scroll')
|
||||
},
|
||||
/**
|
||||
* If someone actually clicked on the modal wrapper and not one of his children the
|
||||
|
|
44
web-frontend/modules/core/mixins/templateCategories.js
Normal file
44
web-frontend/modules/core/mixins/templateCategories.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { clone } from '@baserow/modules/core/utils/object'
|
||||
import { escapeRegExp } from '@baserow/modules/core/utils/string'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
search: '',
|
||||
selectedCategoryId: -1,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* Returns the categories and templates that match the search query.
|
||||
*/
|
||||
matchingCategories() {
|
||||
if (this.search === '') {
|
||||
return this.categories
|
||||
}
|
||||
|
||||
return clone(this.categories)
|
||||
.map((category) => {
|
||||
category.templates = category.templates.filter((template) => {
|
||||
const keywords = template.keywords.split(',')
|
||||
keywords.push(template.name)
|
||||
const regex = new RegExp('(' + escapeRegExp(this.search) + ')', 'i')
|
||||
return keywords.some((value) => value.match(regex))
|
||||
})
|
||||
return category
|
||||
})
|
||||
.filter((category) => category.templates.length > 0)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
selectCategory(id) {
|
||||
this.selectedCategoryId = this.selectedCategoryId === id ? -1 : id
|
||||
},
|
||||
},
|
||||
}
|
|
@ -5,107 +5,31 @@
|
|||
<img src="@baserow/modules/core/static/img/logo.svg" alt="" />
|
||||
</nuxt-link>
|
||||
</h1>
|
||||
<div
|
||||
v-if="invitation !== null"
|
||||
class="alert alert--simple alert-primary alert--has-icon"
|
||||
>
|
||||
<div class="alert__icon">
|
||||
<i class="fas fa-exclamation"></i>
|
||||
</div>
|
||||
<div class="alert__title">Invitation</div>
|
||||
<p class="alert__content">
|
||||
<strong>{{ invitation.invited_by }}</strong> has invited you to join
|
||||
<strong>{{ invitation.group }}</strong
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
<Error :error="error"></Error>
|
||||
<form @submit.prevent="login">
|
||||
<div class="control">
|
||||
<label class="control__label">E-mail address</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
v-if="invitation !== null"
|
||||
ref="email"
|
||||
type="email"
|
||||
class="input input--large"
|
||||
disabled
|
||||
:value="credentials.email"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
ref="email"
|
||||
v-model="credentials.email"
|
||||
:class="{ 'input--error': $v.credentials.email.$error }"
|
||||
type="email"
|
||||
class="input input--large"
|
||||
@blur="$v.credentials.email.$touch()"
|
||||
/>
|
||||
<div v-if="$v.credentials.email.$error" class="error">
|
||||
Please enter a valid e-mail address.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="control__label">Password</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="password"
|
||||
v-model="credentials.password"
|
||||
:class="{ 'input--error': $v.credentials.password.$error }"
|
||||
type="password"
|
||||
class="input input--large"
|
||||
@blur="$v.credentials.password.$touch()"
|
||||
/>
|
||||
<div v-if="$v.credentials.password.$error" class="error">
|
||||
A password is required.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<ul class="action__links">
|
||||
<li v-if="settings.allow_new_signups">
|
||||
<nuxt-link :to="{ name: 'signup' }"> Sign up </nuxt-link>
|
||||
</li>
|
||||
<li>
|
||||
<nuxt-link :to="{ name: 'forgot-password' }">
|
||||
Forgot password
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
:class="{ 'button--loading': loading }"
|
||||
class="button button--large"
|
||||
:disabled="loading"
|
||||
>
|
||||
Sign in
|
||||
<i class="fas fa-lock-open"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<AuthLogin :invitation="invitation" @success="success">
|
||||
<ul class="action__links">
|
||||
<li v-if="settings.allow_new_signups">
|
||||
<nuxt-link :to="{ name: 'signup' }"> Sign up </nuxt-link>
|
||||
</li>
|
||||
<li>
|
||||
<nuxt-link :to="{ name: 'forgot-password' }">
|
||||
Forgot password
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</ul>
|
||||
</AuthLogin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
import { required, email } from 'vuelidate/lib/validators'
|
||||
import error from '@baserow/modules/core/mixins/error'
|
||||
import AuthLogin from '@baserow/modules/core/components/auth/AuthLogin'
|
||||
import groupInvitationToken from '@baserow/modules/core/mixins/groupInvitationToken'
|
||||
import GroupService from '@baserow/modules/core/services/group'
|
||||
|
||||
export default {
|
||||
mixins: [error, groupInvitationToken],
|
||||
components: { AuthLogin },
|
||||
mixins: [groupInvitationToken],
|
||||
layout: 'login',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
credentials: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: 'Login',
|
||||
|
@ -122,72 +46,14 @@ export default {
|
|||
settings: 'settings/get',
|
||||
}),
|
||||
},
|
||||
beforeMount() {
|
||||
if (this.invitation !== null) {
|
||||
this.credentials.email = this.invitation.email
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async login() {
|
||||
this.$v.$touch()
|
||||
if (this.$v.$invalid) {
|
||||
return
|
||||
success() {
|
||||
const { original } = this.$route.query
|
||||
if (original) {
|
||||
this.$nuxt.$router.push(original)
|
||||
} else {
|
||||
this.$nuxt.$router.push({ name: 'dashboard' })
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.hideError()
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('auth/login', {
|
||||
email: this.credentials.email,
|
||||
password: this.credentials.password,
|
||||
})
|
||||
|
||||
// If there is an invitation we can immediately accept that one after the user
|
||||
// successfully signs in.
|
||||
if (
|
||||
this.invitation !== null &&
|
||||
this.invitation.email === this.credentials.email
|
||||
) {
|
||||
await GroupService(this.$client).acceptInvitation(this.invitation.id)
|
||||
}
|
||||
|
||||
const { original } = this.$route.query
|
||||
if (original) {
|
||||
this.$nuxt.$router.push(original)
|
||||
} else {
|
||||
this.$nuxt.$router.push({ name: 'dashboard' })
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.handler) {
|
||||
const response = error.handler.response
|
||||
// Because the API server does not yet respond with proper error codes we
|
||||
// manually have to add the error here.
|
||||
if (response && response.status === 400) {
|
||||
this.showError(
|
||||
'Incorrect credentials',
|
||||
'The provided e-mail address or password is ' + 'incorrect.'
|
||||
)
|
||||
this.credentials.password = ''
|
||||
this.$v.$reset()
|
||||
this.$refs.password.focus()
|
||||
} else {
|
||||
const message = error.handler.getMessage('login')
|
||||
this.showError(message)
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
error.handler.handled()
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
credentials: {
|
||||
email: { required, email },
|
||||
password: { required },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -19,157 +19,28 @@
|
|||
Back to login
|
||||
</nuxt-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="invitation !== null"
|
||||
class="alert alert--simple alert--primary alert--has-icon"
|
||||
>
|
||||
<div class="alert__icon">
|
||||
<i class="fas fa-exclamation"></i>
|
||||
</div>
|
||||
<div class="alert__title">Invitation</div>
|
||||
<p class="alert__content">
|
||||
<strong>{{ invitation.invited_by }}</strong> has invited you to join
|
||||
<strong>{{ invitation.group }}</strong
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
<Error :error="error"></Error>
|
||||
<form @submit.prevent="register">
|
||||
<div class="control">
|
||||
<label class="control__label">E-mail address</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
v-if="invitation !== null"
|
||||
ref="email"
|
||||
type="email"
|
||||
class="input input--large"
|
||||
disabled
|
||||
:value="account.email"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
ref="email"
|
||||
v-model="account.email"
|
||||
:class="{ 'input--error': $v.account.email.$error }"
|
||||
type="text"
|
||||
class="input input--large"
|
||||
@blur="$v.account.email.$touch()"
|
||||
/>
|
||||
<div v-if="$v.account.email.$error" class="error">
|
||||
Please enter a valid e-mail address.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="control__label">Your name</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
v-model="account.name"
|
||||
:class="{ 'input--error': $v.account.name.$error }"
|
||||
type="text"
|
||||
class="input input--large"
|
||||
@blur="$v.account.name.$touch()"
|
||||
/>
|
||||
<div v-if="$v.account.name.$error" class="error">
|
||||
A minimum of two characters is required here.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="control__label">Password</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
v-model="account.password"
|
||||
:class="{ 'input--error': $v.account.password.$error }"
|
||||
type="password"
|
||||
class="input input--large"
|
||||
@blur="$v.account.password.$touch()"
|
||||
/>
|
||||
<div
|
||||
v-if="$v.account.password.$error && !$v.account.password.required"
|
||||
class="error"
|
||||
>
|
||||
A password is required.
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
$v.account.password.$error && !$v.account.password.maxLength
|
||||
"
|
||||
class="error"
|
||||
>
|
||||
A maximum of
|
||||
{{ $v.account.password.$params.maxLength.max }} characters is
|
||||
allowed here.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="control__label">Repeat password</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
v-model="account.passwordConfirm"
|
||||
:class="{ 'input--error': $v.account.passwordConfirm.$error }"
|
||||
type="password"
|
||||
class="input input--large"
|
||||
@blur="$v.account.passwordConfirm.$touch()"
|
||||
/>
|
||||
<div v-if="$v.account.passwordConfirm.$error" class="error">
|
||||
This field must match your password field.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<ul class="action__links">
|
||||
<li>
|
||||
<nuxt-link :to="{ name: 'login' }">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
Back
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
:class="{ 'button--loading': loading }"
|
||||
class="button button--large"
|
||||
:disabled="loading"
|
||||
>
|
||||
Sign up
|
||||
<i class="fas fa-user-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<AuthRegister v-else :invitation="invitation" @success="success">
|
||||
<ul class="action__links">
|
||||
<li>
|
||||
<nuxt-link :to="{ name: 'login' }">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
Back
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</ul>
|
||||
</AuthRegister>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
required,
|
||||
email,
|
||||
sameAs,
|
||||
minLength,
|
||||
maxLength,
|
||||
} from 'vuelidate/lib/validators'
|
||||
|
||||
import { ResponseErrorMessage } from '@baserow/modules/core/plugins/clientHandler'
|
||||
import groupInvitationToken from '@baserow/modules/core/mixins/groupInvitationToken'
|
||||
import error from '@baserow/modules/core/mixins/error'
|
||||
import { mapGetters } from 'vuex'
|
||||
import groupInvitationToken from '@baserow/modules/core/mixins/groupInvitationToken'
|
||||
import AuthRegister from '@baserow/modules/core/components/auth/AuthRegister'
|
||||
|
||||
export default {
|
||||
mixins: [error, groupInvitationToken],
|
||||
components: { AuthRegister },
|
||||
mixins: [groupInvitationToken],
|
||||
layout: 'login',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
account: {
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: 'Create new account',
|
||||
|
@ -180,66 +51,9 @@ export default {
|
|||
settings: 'settings/get',
|
||||
}),
|
||||
},
|
||||
beforeMount() {
|
||||
if (this.invitation !== null) {
|
||||
this.account.email = this.invitation.email
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async register() {
|
||||
this.$v.$touch()
|
||||
if (this.$v.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.hideError()
|
||||
|
||||
try {
|
||||
const values = {
|
||||
name: this.account.name,
|
||||
email: this.account.email,
|
||||
password: this.account.password,
|
||||
}
|
||||
|
||||
// If there is a valid invitation we can add the group invitation token to the
|
||||
// action parameters so that is can be passed along when signing up. That makes
|
||||
// the user accept the group invitation without creating a new group for the
|
||||
// user.
|
||||
if (this.invitation !== null) {
|
||||
values.groupInvitationToken = this.$route.query.groupInvitationToken
|
||||
}
|
||||
|
||||
await this.$store.dispatch('auth/register', values)
|
||||
Object.values(this.$registry.getAll('plugin')).forEach((plugin) => {
|
||||
plugin.userCreated(this.account, this)
|
||||
})
|
||||
this.$nuxt.$router.push({ name: 'dashboard' })
|
||||
} catch (error) {
|
||||
this.loading = false
|
||||
this.handleError(error, 'signup', {
|
||||
ERROR_EMAIL_ALREADY_EXISTS: new ResponseErrorMessage(
|
||||
'User already exists.',
|
||||
'A user with the provided e-mail address already exists.'
|
||||
),
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
account: {
|
||||
email: { required, email },
|
||||
name: {
|
||||
required,
|
||||
minLength: minLength(2),
|
||||
},
|
||||
password: {
|
||||
required,
|
||||
maxLength: maxLength(256),
|
||||
},
|
||||
passwordConfirm: {
|
||||
sameAsPassword: sameAs('password'),
|
||||
},
|
||||
success() {
|
||||
this.$nuxt.$router.push({ name: 'dashboard' })
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -16,7 +16,8 @@ export default (client) => {
|
|||
name,
|
||||
password,
|
||||
authenticate = true,
|
||||
groupInvitationToken = null
|
||||
groupInvitationToken = null,
|
||||
templateId = null
|
||||
) {
|
||||
const values = {
|
||||
name,
|
||||
|
@ -29,6 +30,10 @@ export default (client) => {
|
|||
values.group_invitation_token = groupInvitationToken
|
||||
}
|
||||
|
||||
if (templateId !== null) {
|
||||
values.template_id = templateId
|
||||
}
|
||||
|
||||
return client.post('/user/', values)
|
||||
},
|
||||
sendResetPasswordEmail(email, baseUrl) {
|
||||
|
|
|
@ -46,14 +46,15 @@ export const actions = {
|
|||
*/
|
||||
async register(
|
||||
{ commit, dispatch },
|
||||
{ email, name, password, groupInvitationToken = null }
|
||||
{ email, name, password, groupInvitationToken = null, templateId = null }
|
||||
) {
|
||||
const { data } = await AuthService(this.$client).register(
|
||||
email,
|
||||
name,
|
||||
password,
|
||||
true,
|
||||
groupInvitationToken
|
||||
groupInvitationToken,
|
||||
templateId
|
||||
)
|
||||
setToken(data.token, this.app)
|
||||
commit('SET_USER_DATA', data)
|
||||
|
|
|
@ -69,9 +69,9 @@ export const actions = {
|
|||
* If not already loading or loaded it will trigger the fetchAll action which
|
||||
* will load all the groups for the user.
|
||||
*/
|
||||
loadAll({ state, dispatch }) {
|
||||
async loadAll({ state, dispatch }) {
|
||||
if (!state.loaded && !state.loading) {
|
||||
dispatch('fetchAll')
|
||||
await dispatch('fetchAll')
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
@ -111,6 +111,7 @@ export const actions = {
|
|||
async create({ commit, dispatch }, values) {
|
||||
const { data } = await GroupService(this.$client).create(values)
|
||||
dispatch('forceCreate', data)
|
||||
return data
|
||||
},
|
||||
/**
|
||||
* Forcefully create an item in the store without making a call to the server.
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
<template>
|
||||
<div>
|
||||
<header class="layout__col-2-1 header">
|
||||
<header
|
||||
ref="header"
|
||||
class="layout__col-2-1 header"
|
||||
:class="{ 'header--overflow': headerOverflow }"
|
||||
>
|
||||
<div v-show="tableLoading" class="header__loading"></div>
|
||||
<ul v-if="!tableLoading" class="header__filter">
|
||||
<li class="header__filter-item header__filter-item--grids">
|
||||
|
@ -16,13 +20,15 @@
|
|||
)
|
||||
"
|
||||
>
|
||||
<span v-if="hasSelectedView">
|
||||
<template v-if="hasSelectedView">
|
||||
<i
|
||||
class="header__filter-icon header-filter-icon--view fas"
|
||||
:class="'fa-' + view._.type.iconClass"
|
||||
></i>
|
||||
{{ view.name }}
|
||||
</span>
|
||||
<span class="header__filter-name header__filter-name--forced">{{
|
||||
view.name
|
||||
}}</span>
|
||||
</template>
|
||||
<span v-else>
|
||||
<i
|
||||
class="header__filter-icon header-filter-icon-no-choice fas fa-caret-square-down"
|
||||
|
@ -35,6 +41,7 @@
|
|||
:table="table"
|
||||
:views="views"
|
||||
:read-only="readOnly"
|
||||
:header-overflow="headerOverflow"
|
||||
@selected-view="$emit('selected-view', $event)"
|
||||
></ViewsContext>
|
||||
</li>
|
||||
|
@ -96,6 +103,8 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import ResizeObserver from 'resize-observer-polyfill'
|
||||
|
||||
import { RefreshCancelledError } from '@baserow/modules/core/errors'
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
import ViewsContext from '@baserow/modules/database/components/view/ViewsContext'
|
||||
|
@ -143,8 +152,9 @@ export default {
|
|||
required: true,
|
||||
},
|
||||
view: {
|
||||
validator: (prop) => typeof prop === 'object' || prop === undefined,
|
||||
type: Object,
|
||||
required: true,
|
||||
validator: (prop) => typeof prop === 'object' || prop === undefined,
|
||||
},
|
||||
tableLoading: {
|
||||
type: Boolean,
|
||||
|
@ -165,6 +175,9 @@ export default {
|
|||
return {
|
||||
// Shows a small spinning loading animation when the view is being refreshed.
|
||||
viewLoading: false,
|
||||
// Indicates if the elements within the header are overflowing. In case of true,
|
||||
// we can hide certain values to make sure that it fits within the header.
|
||||
headerOverflow: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -179,11 +192,25 @@ export default {
|
|||
)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
tableLoading(value) {
|
||||
if (!value) {
|
||||
this.$nextTick(() => {
|
||||
this.checkHeaderOverflow()
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
beforeMount() {
|
||||
this.$bus.$on('table-refresh', this.refresh)
|
||||
},
|
||||
mounted() {
|
||||
this.$el.resizeObserver = new ResizeObserver(this.checkHeaderOverflow)
|
||||
this.$el.resizeObserver.observe(this.$el)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$bus.$off('table-refresh', this.refresh)
|
||||
this.$el.resizeObserver.unobserve(this.$el)
|
||||
},
|
||||
methods: {
|
||||
getViewComponent(view) {
|
||||
|
@ -194,6 +221,26 @@ export default {
|
|||
const type = this.$registry.get('view', view.type)
|
||||
return type.getHeaderComponent()
|
||||
},
|
||||
/**
|
||||
* When the window resizes, we want to check if the content of the header is
|
||||
* overflowing. If that is the case, we want to make some space by removing some
|
||||
* content. We do this by copying the header content into a new HTMLElement and
|
||||
* check if the elements still fit within the header. We copy the html because we
|
||||
* want to measure the header in the full width state.
|
||||
*/
|
||||
checkHeaderOverflow() {
|
||||
const header = this.$refs.header
|
||||
const width = header.getBoundingClientRect().width
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.innerHTML = header.outerHTML
|
||||
const el = wrapper.childNodes[0]
|
||||
el.style = `position: absolute; left: 0; top: 0; width: ${width}px; overflow: auto;`
|
||||
el.classList.remove('header--overflow')
|
||||
document.body.appendChild(el)
|
||||
this.headerOverflow =
|
||||
el.clientWidth < el.scrollWidth || el.clientHeight < el.scrollHeight
|
||||
document.body.removeChild(el)
|
||||
},
|
||||
/**
|
||||
* Refreshes the whole view. All data will be reloaded and it will visually look
|
||||
* the same as seeing the view for the first time.
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 4)"
|
||||
>
|
||||
<i class="header__filter-icon fas fa-filter"></i>
|
||||
{{ filterTitle }}
|
||||
<span class="header__filter-name">{{ filterTitle }}</span>
|
||||
</a>
|
||||
<ViewFilterContext
|
||||
ref="context"
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 4)"
|
||||
>
|
||||
<i class="header__filter-icon fas fa-sort"></i>
|
||||
{{ sortTitle }}
|
||||
<span class="header__filter-name">{{ sortTitle }}</span>
|
||||
</a>
|
||||
<ViewSortContext
|
||||
ref="context"
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 4)"
|
||||
>
|
||||
<i class="header__filter-icon fas fa-eye-slash"></i>
|
||||
{{ hiddenFieldsTitle }}
|
||||
<span class="header__filter-name">{{ hiddenFieldsTitle }}</span>
|
||||
</a>
|
||||
<GridViewHideContext
|
||||
ref="context"
|
||||
|
|
|
@ -30,6 +30,7 @@
|
|||
"nuxt": "^2.14.12",
|
||||
"nuxt-env": "^0.1.0",
|
||||
"papaparse": "^5.3.0",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sass-loader": "^10.1.1",
|
||||
"thenby": "^1.3.4",
|
||||
"vuejs-datepicker": "^1.6.2",
|
||||
|
|
|
@ -9989,6 +9989,11 @@ reserved-words@^0.1.2:
|
|||
resolved "https://registry.yarnpkg.com/reserved-words/-/reserved-words-0.1.2.tgz#00a0940f98cd501aeaaac316411d9adc52b31ab1"
|
||||
integrity sha1-AKCUD5jNUBrqqsMWQR2a3FKzGrE=
|
||||
|
||||
resize-observer-polyfill@^1.5.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
|
||||
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
|
||||
|
||||
resolve-cwd@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d"
|
||||
|
|
Loading…
Add table
Reference in a new issue