* Add note to public page

* Add project chooser on public view

* Optimize loading order

So much nicer to use now

Closes #298

* Fix the private project switcher too

* Add transition to public view

* Prevent project switching if youre already on that project

* [WIP] Add reset password form

* Add request password reset page

* Add jwt-payload util

* Install base-64

* Fix test typing

* Add new errors to translations

* Finish reset password flow

* Fix foreground color on v-notice

* Fix tests

* Allow code in translateError + render project error translated on login

* Remove wrong reference to error component

* Render project key if name is unknown

* Fix date-fns version

* Fix tests
This commit is contained in:
Rijk van Zanten
2020-04-09 19:06:15 -04:00
committed by GitHub
parent 153eaec78a
commit 7485a97a3b
39 changed files with 710 additions and 244 deletions

View File

@@ -22,6 +22,7 @@
"@types/lodash": "^4.14.149",
"@vue/composition-api": "^0.5.0",
"axios": "^0.19.2",
"base-64": "^0.1.0",
"date-fns": "^2.12.0",
"lodash": "^4.17.15",
"marked": "^0.8.2",
@@ -48,6 +49,7 @@
"@storybook/addons": "^5.3.18",
"@storybook/core": "^5.3.18",
"@storybook/vue": "^5.3.18",
"@types/base-64": "^0.1.3",
"@types/jest": "^25.2.1",
"@types/marked": "^0.7.3",
"@typescript-eslint/eslint-plugin": "^2.27.0",

View File

@@ -1,10 +1,10 @@
import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
import { onRequest, onResponse, onError, getRootPath, Error } from './api';
import { onRequest, onResponse, onError, getRootPath, RequestError } from './api';
import * as auth from '@/auth';
import { useRequestsStore } from '@/stores/requests';
const defaultError: Error = {
const defaultError: RequestError = {
config: {},
isAxiosError: false,
toJSON: () => ({}),

View File

@@ -14,7 +14,7 @@ interface Response extends AxiosResponse {
config: RequestConfig;
}
export interface Error extends AxiosError {
export interface RequestError extends AxiosError {
response: Response;
}
@@ -37,7 +37,7 @@ export const onResponse = (response: AxiosResponse | Response) => {
return response;
};
export const onError = async (error: Error) => {
export const onError = async (error: RequestError) => {
const requestsStore = useRequestsStore();
const id = (error.response.config as RequestConfig).id;
requestsStore.endRequest(id);

View File

@@ -15,7 +15,6 @@ import { defineComponent, toRefs, watch, computed } from '@vue/composition-api';
import { useAppStore } from '@/stores/app';
import { useUserStore } from '@/stores/user';
import { useProjectsStore } from '@/stores/projects';
import { ProjectWithKey } from './stores/projects/types';
export default defineComponent({
setup() {
@@ -26,18 +25,9 @@ export default defineComponent({
const { hydrating } = toRefs(appStore.state);
const brandStyle = computed(() => {
if (
projectsStore.currentProject.value &&
projectsStore.currentProject.value.hasOwnProperty('api')
) {
const project = projectsStore.currentProject.value as ProjectWithKey;
return {
'--brand': project?.api?.project_color || 'var(--primary)',
};
}
return null;
return {
'--brand': projectsStore.currentProject.value?.color || 'var(--primary)',
};
});
watch(

View File

@@ -67,13 +67,13 @@ export async function logout(optionsRaw: LogoutOptions = {}) {
// You can't logout of a project if you're not in a project
if (currentProjectKey === null) return;
await dehydrate();
// Only if the user manually signed out should we kill the session by hitting the logout endpoint
if (options.reason === LogoutReason.SIGN_OUT) {
await api.post(`/${currentProjectKey}/auth/logout`);
}
await dehydrate();
if (options.navigate === true) {
const location: RawLocation = {
path: `/${currentProjectKey}/login`,

View File

@@ -63,7 +63,7 @@ export default defineComponent({
<style lang="scss" scoped>
.v-notice {
--v-notice-color: var(--foreground-normal);
--v-notice-color: var(--primary);
--v-notice-background-color: var(--primary-alt);
--v-notice-icon-color: var(--primary);
@@ -71,6 +71,7 @@ export default defineComponent({
align-items: center;
justify-content: flex-start;
width: auto;
min-height: var(--input-height);
padding: 12px 16px;
color: var(--v-notice-color);
background-color: var(--v-notice-background-color);
@@ -83,16 +84,19 @@ export default defineComponent({
&.success {
--v-notice-icon-color: var(--success);
--v-notice-background-color: var(--success-alt);
--v-notice-color: var(--success);
}
&.warning {
--v-notice-icon-color: var(--warning);
--v-notice-background-color: var(--warning-alt);
--v-notice-color: var(--warning);
}
&.danger {
--v-notice-icon-color: var(--danger);
--v-notice-background-color: var(--danger-alt);
--v-notice-color: var(--danger);
}
}
</style>

View File

@@ -5,6 +5,7 @@ import { useUserStore } from '@/stores/user/';
import { useRequestsStore } from '@/stores/requests/';
import { useCollectionPresetsStore } from '@/stores/collection-presets/';
import { useSettingsStore } from '@/stores/settings/';
import { useProjectsStore } from '@/stores/projects/';
type GenericStore = {
id: string;
@@ -22,6 +23,7 @@ export function useStores(
useRequestsStore,
useCollectionPresetsStore,
useSettingsStore,
useProjectsStore,
]
) {
return stores.map((useStore) => useStore()) as GenericStore[];

View File

@@ -126,6 +126,28 @@
"help_and_docs": "Help & Docs",
"errors": {
"11": "Can't Reach Database",
"100": "Incorrect Email/Password",
"101": "Logged-out from Inactivity",
"102": "Logged-out from Inactivity",
"103": "User Suspended",
"105": "Reset link expired",
"106": "Incorrect Email/Password",
"107": "User Not Found",
"111": "Enter One-Time Password",
"112": "Wrong One-Time Password",
"114": "Incorrect Email/Password",
"115": "SSO is not allowed when 2FA is enabled",
"503": "Email couldn't be sent. Please verify the API's configuration",
"-1": "Couldn't Reach API"
},
"unexpected_error": "An unexpected error occured",
"password_reset_sent": "We've sent you a secure link to reset your password",
"password_reset_successful": "Password successfully reset",
"about_directus": "About Directus",
"activity_log": "Activity Log",
@@ -319,20 +341,6 @@
"environment": "Environment",
"equal_to": "Equal to",
"error_unknown": "Unknown error. Try again later.",
"errors": {
"11": "Can Not Reach Database",
"100": "Incorrect Email/Password",
"101": "Logged-out from Inactivity",
"102": "Logged-out from Inactivity",
"103": "User Suspended",
"106": "Incorrect Email/Password",
"107": "User Not Found",
"111": "Enter One-Time Password",
"112": "Wrong One-Time Password",
"114": "Incorrect Email/Password",
"115": "SSO is not allowed when 2FA is enabled",
"-1": "Couldn't Reach API"
},
"esc_cancel": "Escape will cancel and close the window.",
"event_count": "No Events | One Event | {count} Events",
"existing": "Existing",
@@ -499,8 +507,6 @@
"otp": "One-Time Password",
"password": "Password",
"password_reset_sending": "Sending email...",
"password_reset_sent": "If a valid user with this email address exists in Directus, we've sent you a secure link to reset your password.",
"password_reset_successful": "Password successfully reset.",
"permission_states": {
"always": "Always",
"create": "Create",

View File

@@ -1,6 +1,7 @@
import Vue from 'vue';
import VueI18n from 'vue-i18n';
import { merge } from 'lodash';
import { RequestError } from '@/api';
import enUSBase from './en-US/index.json';
import enUSInterfaces from './en-US/interfaces.json';
@@ -100,3 +101,20 @@ export async function setLanguage(lang: Language): Promise<boolean> {
}
export default i18n;
export function translateAPIError(error: RequestError | number) {
const defaultMsg = i18n.t('unexpected_error');
let code = error;
if (typeof error !== 'number') {
code = error?.response?.data?.error?.code;
}
if (!error) return defaultMsg;
if (!code === undefined) return defaultMsg;
const key = `errors.${code}`;
const exists = i18n.te(key);
if (exists === false) return defaultMsg;
return i18n.t(key);
}

View File

@@ -2,6 +2,7 @@ import VueRouter, { NavigationGuard, RouteConfig, Route } from 'vue-router';
import { useProjectsStore } from '@/stores/projects';
import LoginRoute from '@/routes/login';
import LogoutRoute from '@/routes/logout';
import ResetPasswordRoute from '@/routes/reset-password';
import ProjectChooserRoute from '@/routes/project-chooser';
import { checkAuth } from '@/auth';
import { hydrate, dehydrate } from '@/hydrate';
@@ -45,10 +46,21 @@ export const defaultRoutes: RouteConfig[] = [
public: true,
},
},
{
name: 'reset-password',
path: '/:project/reset-password',
component: ResetPasswordRoute,
meta: {
public: true,
},
},
{
name: 'logout',
path: '/:project/logout',
component: LogoutRoute,
meta: {
public: true,
},
},
/**
* @NOTE
@@ -98,9 +110,10 @@ export const onBeforeEach: NavigationGuard = async (to, from, next) => {
}
// Keep the projects store currentProjectKey in sync with the route
// If we switch projects to a public route, we don't need the store to be hyrdated
if (to.params.project && projectsStore.state.currentProjectKey !== to.params.project) {
// If the store is hydrated for the current project, make sure to dehydrate it
if (appStore.state.hydrated === true) {
if (to.meta?.public !== true && appStore.state.hydrated === true) {
appStore.state.hydrating = true;
await dehydrate();
}
@@ -115,13 +128,14 @@ export const onBeforeEach: NavigationGuard = async (to, from, next) => {
// The store can only be hydrated if you're an authenticated user. If the store is hydrated, we
// can safely assume you're logged in
if (appStore.state.hydrated === false) {
if (to.meta?.public !== true && appStore.state.hydrated === false) {
const authenticated = await checkAuth();
if (authenticated === true) {
appStore.state.hydrating = false;
await hydrate();
} else if (to.meta?.public !== true) {
appStore.state.hydrating = false;
return next(`/${to.params.project}/login`);
}
}

View File

@@ -1,45 +1,66 @@
<template>
<div class="continue-as">
<p v-html="$t('continue_as', { name })" />
<div class="actions">
<router-link :to="signOutLink">
{{ $t('sign_out') }}
</router-link>
<v-button large :to="lastPage">{{ $t('continue') }}</v-button>
</div>
<v-progress-circular v-if="loading" indeterminate />
<template v-else>
<p v-html="$t('continue_as', { name })" />
<div class="actions">
<router-link :to="signOutLink">
{{ $t('sign_out') }}
</router-link>
<v-button large @click="hydrateAndLogin">{{ $t('continue') }}</v-button>
</div>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from '@vue/composition-api';
import useUserStore from '../../../../stores/user';
import useProjectsStore from '../../../../stores/projects';
import { defineComponent, computed, watch, ref } from '@vue/composition-api';
import useProjectsStore from '@/stores/projects';
import api from '@/api';
import { hydrate } from '@/hydrate';
import router from '@/router';
export default defineComponent({
setup() {
const userStore = useUserStore();
const projectsStore = useProjectsStore();
const name = computed<string>(
() =>
userStore.state.currentUser?.first_name +
' ' +
userStore.state.currentUser?.last_name
);
/** @NOTE
* This component is only rendered if the current user already exists. It's safe to assume
* that userStore.state.currentUser exists in this context
*/
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const lastPage = userStore.state.currentUser!.last_page;
const signOutLink = computed<string>(() => {
return `/${projectsStore.state.currentProjectKey}/logout`;
});
return { name, lastPage, signOutLink };
const loading = ref(false);
const error = ref(null);
const name = ref<string>(null);
const lastPage = ref<string>(null);
watch(() => projectsStore.state.currentProjectKey, fetchUser);
return { name, lastPage, signOutLink, loading, error, hydrateAndLogin };
async function fetchUser(projectKey: string | null) {
loading.value = true;
error.value = null;
try {
const response = await api.get(`/${projectKey}/users/me`, {
params: {
fields: ['first_name', 'last_name', 'last_page'],
},
});
name.value = response.data.data.first_name + ' ' + response.data.data.last_name;
lastPage.value = response.data.data.last_page;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
async function hydrateAndLogin() {
await hydrate();
router.push(`/${projectsStore.state.currentProjectKey}/collections/`);
}
},
});
</script>

View File

@@ -15,15 +15,23 @@
:placeholder="$t('password')"
full-width
/>
<v-button type="submit" :loading="loggingIn" x-large>{{ $t('sign_in') }}</v-button>
<v-notice danger v-if="error">
{{ errorFormatted }}
</v-notice>
<div class="buttons">
<v-button type="submit" :loading="loggingIn" large>{{ $t('sign_in') }}</v-button>
<router-link :to="forgotLink">{{ $t('forgot_password') }}</router-link>
</div>
</form>
</template>
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api';
import { defineComponent, ref, computed } from '@vue/composition-api';
import router from '@/router';
import { useProjectsStore } from '@/stores/projects';
import { login } from '@/auth';
import { RequestError } from '@/api';
import { translateAPIError } from '@/lang';
export default defineComponent({
setup() {
@@ -32,8 +40,20 @@ export default defineComponent({
const loggingIn = ref(false);
const email = ref<string>(null);
const password = ref<string>(null);
const error = ref<RequestError>(null);
return { email, password, onSubmit, loggingIn };
const errorFormatted = computed(() => {
if (error.value) {
return translateAPIError(error.value);
}
return null;
});
const forgotLink = computed(() => {
return `/${projectsStore.state.currentProjectKey}/reset-password`;
});
return { errorFormatted, error, email, password, onSubmit, loggingIn, forgotLink };
async function onSubmit() {
if (email.value === null || password.value === null) return;
@@ -49,8 +69,8 @@ export default defineComponent({
});
router.push(`/${currentProjectKey}/collections/`);
} catch (error) {
console.warn(error);
} catch (err) {
error.value = err;
} finally {
loggingIn.value = false;
}
@@ -60,7 +80,14 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
.v-input {
margin-bottom: 32px;
.v-input,
.v-notice {
margin-bottom: 20px;
}
.buttons {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>

View File

@@ -1,4 +0,0 @@
import ProjectError from './project-error.vue';
export { ProjectError };
export default ProjectError;

View File

@@ -1,20 +0,0 @@
<template functional>
<v-notice danger>[{{ props.status }}] {{ props.error }}</v-notice>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
export default defineComponent({
props: {
error: {
type: String,
required: true,
},
status: {
type: Number,
required: true,
},
},
});
</script>

View File

@@ -2,12 +2,10 @@
<public-view>
<h1 class="type-title">{{ $t('sign_in') }}</h1>
<continue-as v-if="alreadyAuthenticated" />
<project-error
v-else-if="currentProject.error"
:error="currentProject.error"
:status="currentProject.status"
/>
<continue-as v-if="currentProject.authenticated" />
<v-notice danger v-else-if="currentProject && currentProject.error">
{{ errorFormatted }}
</v-notice>
<login-form v-else />
<template #notice>
@@ -19,31 +17,32 @@
<script lang="ts">
import { defineComponent, computed } from '@vue/composition-api';
import { useUserStore } from '@/stores/user';
import { notEmpty } from '@/utils/is-empty';
import LoginForm from './components/login-form/';
import ContinueAs from './components/continue-as/';
import ProjectError from './components/project-error/';
import useProjectsStore from '../../stores/projects';
import { translateAPIError } from '@/lang';
export default defineComponent({
components: { LoginForm, ContinueAs, ProjectError },
components: { LoginForm, ContinueAs },
setup() {
const userStore = useUserStore();
const projectsStore = useProjectsStore();
const currentProject = projectsStore.currentProject;
const alreadyAuthenticated = computed<boolean>(() =>
notEmpty(userStore.state.currentUser?.id)
);
const errorFormatted = computed(() => {
if (projectsStore.currentProject.value.error) {
return translateAPIError(projectsStore.currentProject.value.error.code);
}
return { alreadyAuthenticated, currentProject };
return null;
});
return { errorFormatted, currentProject: projectsStore.currentProject };
},
});
</script>
<style lang="scss" scoped>
h1 {
margin-bottom: 44px;
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,4 @@
import ResetPasswordRoute from './reset-password.vue';
export { ResetPasswordRoute };
export default ResetPasswordRoute;

View File

@@ -0,0 +1,90 @@
<template>
<form @submit.prevent="onSubmit">
<v-input
autofocus
autocomplete="username"
type="email"
v-model="email"
:placeholder="$t('email')"
full-width
/>
<v-notice success v-if="done">{{ $t('password_reset_sent') }}</v-notice>
<v-notice danger v-if="error">
{{ errorFormatted }}
</v-notice>
<div class="buttons">
<v-button type="submit" :loading="sending" large>{{ $t('reset') }}</v-button>
<router-link :to="signInLink">{{ $t('sign_in') }}</router-link>
</div>
</form>
</template>
<script lang="ts">
import { defineComponent, ref, computed } from '@vue/composition-api';
import useProjectsStore from '../../stores/projects';
import api from '@/api';
import { translateAPIError } from '@/lang';
import { RequestError } from '@/api';
export default defineComponent({
setup() {
const projectsStore = useProjectsStore();
const email = ref(null);
const sending = ref(false);
const error = ref<RequestError>(null);
const done = ref(false);
const errorFormatted = computed(() => {
if (error.value) {
return translateAPIError(error.value);
}
return null;
});
const signInLink = computed(() => `/${projectsStore.state.currentProjectKey}/login`);
return {
sending,
error,
done,
email,
currentProject: projectsStore.currentProject,
onSubmit,
signInLink,
errorFormatted,
};
async function onSubmit() {
sending.value = true;
error.value = null;
try {
await api.post(`/${projectsStore.state.currentProjectKey}/auth/password/request`, {
email: email.value,
});
done.value = true;
} catch (err) {
error.value = err;
} finally {
sending.value = false;
}
}
},
});
</script>
<style lang="scss" scoped>
.buttons {
display: flex;
align-items: center;
justify-content: space-between;
}
.v-input,
.v-notice {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<public-view>
<h1 class="type-title">{{ $t('reset_password') }}</h1>
<request-form v-if="!resetToken" />
<reset-form :token="resetToken" v-else />
<template #notice>
<v-icon name="lock_outlined" left />
{{ $t('not_authenticated') }}
</template>
</public-view>
</template>
<script lang="ts">
import router from '@/router';
import { defineComponent, computed } from '@vue/composition-api';
import RequestForm from './request.vue';
import ResetForm from './reset.vue';
export default defineComponent({
components: { RequestForm, ResetForm },
setup() {
const resetToken = computed(() => router.currentRoute.query.token);
return {
resetToken,
};
},
});
</script>
<style lang="scss" scoped>
h1 {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<form @submit.prevent="onSubmit">
<v-input :value="email" disabled full-width />
<v-input
:placeholder="$t('password')"
autofocus
autocomplete="username"
type="password"
full-width
v-model="password"
:disabled="done"
/>
<v-notice success v-if="done">{{ $t('password_reset_successful') }}</v-notice>
<v-notice danger v-if="error">
{{ errorFormatted }}
</v-notice>
<v-button v-if="!done" type="submit" :loading="resetting" large>{{ $t('reset') }}</v-button>
<v-button v-else large :to="signInLink">{{ $t('sign_in') }}</v-button>
</form>
</template>
<script lang="ts">
import { defineComponent, ref, computed } from '@vue/composition-api';
import useProjectsStore from '../../stores/projects';
import api from '@/api';
import { translateAPIError } from '@/lang';
import { RequestError } from '@/api';
import jwtPayload from '@/utils/jwt-payload';
export default defineComponent({
props: {
token: {
type: String,
required: true,
},
},
setup(props) {
const projectsStore = useProjectsStore();
const password = ref(null);
const resetting = ref(false);
const error = ref<RequestError>(null);
const done = ref(false);
const errorFormatted = computed(() => {
if (error.value) {
return translateAPIError(error.value);
}
return null;
});
const signInLink = computed(() => `/${projectsStore.state.currentProjectKey}/login`);
const email = computed(() => jwtPayload(props.token).email);
return {
resetting,
error,
done,
password,
currentProject: projectsStore.currentProject,
onSubmit,
signInLink,
errorFormatted,
email,
};
async function onSubmit() {
resetting.value = true;
error.value = null;
try {
await api.post(`/${projectsStore.state.currentProjectKey}/auth/password/reset`, {
password: password.value,
token: props.token,
});
done.value = true;
} catch (err) {
error.value = err;
} finally {
resetting.value = false;
}
}
},
});
</script>
<style lang="scss" scoped>
.v-input,
.v-notice {
margin-bottom: 20px;
}
</style>

View File

@@ -8,37 +8,6 @@ describe('Stores / Projects', () => {
Vue.use(VueCompositionAPI);
});
describe('Getters / currentProject', () => {
const dummyProject = {
key: 'my-project',
api: {
requires2FA: false,
project_color: '#abcabc',
project_logo: null,
project_background: null,
project_foreground: null,
project_name: 'Test',
project_public_note: '',
default_locale: 'en-US',
telemetry: true,
},
};
it('Returns the correct project based on the currentProjectKey state', () => {
const projectsStore = useProjectsStore({});
projectsStore.state.projects = [dummyProject];
projectsStore.state.currentProjectKey = 'my-project';
expect(projectsStore.currentProject.value).toEqual(dummyProject);
});
it('Returns null if non-existing project is read', () => {
const projectsStore = useProjectsStore({});
projectsStore.state.projects = [dummyProject];
projectsStore.state.currentProjectKey = 'non-existing-project';
expect(projectsStore.currentProject.value).toEqual(null);
});
});
describe('Actions / setCurrentProject', () => {
it('Sets the currentProjectKey state to the passed key if project exists', async () => {
const spy = jest.spyOn(api, 'get');
@@ -117,8 +86,8 @@ describe('Stores / Projects', () => {
expect(projectsStore.state.error).toBe(null);
expect(projectsStore.state.projects).toEqual([
{ key: 'my-project' },
{ key: 'another-project' },
{ key: 'my-project', authenticated: false },
{ key: 'another-project', authenticated: false },
]);
});
@@ -188,8 +157,16 @@ describe('Stores / Projects', () => {
await projectsStore.getProjects();
expect(projectsStore.state.projects).toEqual([
{ key: 'my-project' },
{ key: 'another-project', error: 'error message', status: 500 },
{ key: 'my-project', authenticated: false },
{
key: 'another-project',
error: {
code: 10,
message: 'error message',
},
status: 500,
authenticated: false,
},
]);
});
@@ -220,8 +197,16 @@ describe('Stores / Projects', () => {
await projectsStore.getProjects();
expect(projectsStore.state.projects).toEqual([
{ key: 'my-project' },
{ key: 'another-project', error: 'Error fallback', status: 500 },
{ key: 'my-project', authenticated: false },
{
key: 'another-project',
error: {
message: 'Error fallback',
code: null,
},
status: 500,
authenticated: false,
},
]);
});
});

View File

@@ -16,8 +16,27 @@ export const useProjectsStore = createStore({
currentProjectKey: null as string | null,
}),
getters: {
currentProject: (state): ProjectWithKey | null => {
return state.projects?.find(({ key }) => key === state.currentProjectKey) || null;
formatted: (state) => {
return state.projects?.map((project) => {
return {
key: project.key,
authenticated: project?.authenticated || false,
name: project?.api?.project_name || null,
error: project?.error || null,
foregroundImage: project?.api?.project_foreground?.full_url || null,
backgroundImage: project?.api?.project_background?.full_url || null,
logo: project?.api?.project_logo?.full_url || null,
color: project?.api?.project_color || null,
note: project?.api?.project_public_note || null,
};
});
},
currentProject: (state, getters) => {
return (
getters.formatted.value?.find(
({ key }: { key: string }) => key === state.currentProjectKey
) || null
);
},
},
actions: {
@@ -49,6 +68,20 @@ export const useProjectsStore = createStore({
return true;
},
// Even though the projects are fetched on first load, we have to refresh them to make sure
// we have the updated server information for the current project. It also gives us a chance
// to update the authenticated state, for smoother project switching in the private view
async hydrate() {
await this.getProjects();
},
// This is the only store that's supposed to load data on dehydration. By re-fetching the
// projects, we make sure the login views and authenticated states will be up to date. It
// also ensures that the potentially private server info is purged from the store.
async dehydrate() {
await this.getProjects();
},
async getProjects() {
try {
const projectsResponse = await api.get('/server/projects');
@@ -61,13 +94,19 @@ export const useProjectsStore = createStore({
projects.push({
key: projectKeys[index],
...projectInfoResponse.data.data,
authenticated:
projectInfoResponse?.data?.data?.hasOwnProperty('server') || false,
});
} catch (error) {
/* istanbul ignore next */
projects.push({
key: projectKeys[index],
status: error.response?.status,
error: error.response?.data?.error?.message ?? error.message,
error: {
message: error.response?.data?.error?.message ?? error.message,
code: error.response?.data?.error?.code || null,
},
authenticated: false,
});
}
}

View File

@@ -44,4 +44,5 @@ export interface Project {
export interface ProjectWithKey extends Project {
key: string;
authenticated: boolean;
}

View File

@@ -8,6 +8,8 @@ export const useUserStore = createStore({
id: 'userStore',
state: () => ({
currentUser: null as User | null,
loading: false,
error: null,
}),
getters: {
fullName(state) {
@@ -20,13 +22,21 @@ export const useUserStore = createStore({
const projectsStore = useProjectsStore();
const currentProjectKey = projectsStore.state.currentProjectKey;
const { data } = await api.get(`/${currentProjectKey}/users/me`, {
params: {
fields: '*,avatar.data',
},
});
this.state.loading = true;
this.state.currentUser = data.data;
try {
const { data } = await api.get(`/${currentProjectKey}/users/me`, {
params: {
fields: '*,avatar.data',
},
});
this.state.currentUser = data.data;
} catch (error) {
this.state.error = error;
} finally {
this.state.loading = false;
}
},
async dehydrate() {
this.reset();

View File

@@ -0,0 +1,4 @@
import jwtPayload from './jwt-payload';
export { jwtPayload };
export default jwtPayload;

View File

@@ -0,0 +1,9 @@
import { decode } from 'base-64';
export default function jwtPayload(token: string) {
const payloadBase64 = token.split('.')[1].replace('-', '+').replace('_', '/');
const payloadDecoded = decode(payloadBase64);
const payloadObject = JSON.parse(payloadDecoded);
return payloadObject;
}

View File

@@ -0,0 +1,11 @@
# JWT Payload
Get the payload from a JWT token.
## Usage
```js
jwtPayload('eyJa ... Gm18');
// => { exp: 1586466920, [key: string]: any }
```

View File

@@ -73,6 +73,7 @@ export const withCustomLogo = () =>
php_api: 'fpm-fcgi',
},
},
authenticated: true,
},
];
projectsStore.state.currentProjectKey = 'my-project';

View File

@@ -1,7 +1,7 @@
<template>
<div class="project-chooser">
<button class="toggle" :disabled="projects.length === 1" @click="active = !active">
{{ currentProject.api.project_name }}
{{ currentProject.name }}
</button>
<transition-expand>
<div v-if="active" class="options-wrapper">
@@ -17,7 +17,7 @@
<v-radio
:inputValue="currentProjectKey"
:value="project.key"
:label="(project.api && project.api.project_name) || project.key"
:label="project.name || project.key"
/>
</router-link>
</div>

View File

@@ -1,4 +0,0 @@
import PublicViewLogo from './logo.vue';
export { PublicViewLogo };
export default PublicViewLogo;

View File

@@ -1,22 +0,0 @@
<template functional>
<a href="https://directus.io" rel="noopener noreferrer" target="_blank" class="logo">
<img
v-tooltip.right.inverted="`Directus v${props.version}`"
alt="Directus Logo"
src="./logo-dark.svg"
/>
</a>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
export default defineComponent({
props: {
version: {
type: String,
required: true,
},
},
});
</script>

View File

@@ -1,36 +0,0 @@
# Public View Logo
Renders the Directus logo and shows the current version of Directus on hover.
## Usage
```html
<template>
<public-view-logo version="9.0.0" />
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import PublicViewLogo from '@/views/public/components/logo';
export default {
components: {
PublicViewLogo
}
}
</script>
```
## Props
| Prop | Description | Default |
|-----------|---------------------------------|---------|
| `version` | The version to display on hover | -- |
## Events
n/a
## Slots
n/a
## CSS Variables
n/a

View File

@@ -0,0 +1,4 @@
import ProjectChooser from './project-chooser.vue';
export { ProjectChooser };
export default ProjectChooser;

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,6 +1,6 @@
import markdown from './readme.md';
import { defineComponent } from '@vue/composition-api';
import PublicViewLogo from './logo.vue';
import ProjectChooser from './project-chooser.vue';
import withPadding from '../../../../../.storybook/decorators/with-padding';
export default {
@@ -13,8 +13,8 @@ export default {
export const basic = () =>
defineComponent({
components: { PublicViewLogo },
components: { ProjectChooser },
template: `
<public-view-logo version="9.0.0" />
`,
<project-chooser />
`,
});

View File

@@ -0,0 +1,85 @@
<template>
<v-menu show-arrow placement="bottom" close-on-content-click>
<template #activator="{ toggle }">
<div class="project-chooser" @click="toggle">
<div class="public-view-logo" v-if="project && project.logo">
<img :src="project.logo" :alt="project.name || project.key" />
</div>
<img v-else class="default-logo" src="./logo-dark.svg" alt="Directus" />
<h1 class="title type-title">{{ project && (project.name || project.key) }}</h1>
<v-icon name="expand_more" />
</div>
</template>
<v-list dense>
<v-list-item
v-for="availableProject in projects"
:key="availableProject.key"
:active="availableProject.key === project.key"
:disabled="availableProject.key === project.key"
@click="toProject(availableProject.key)"
>
<v-list-item-icon><v-icon name="launch" /></v-list-item-icon>
<v-list-item-content>
{{ availableProject.name || availableProject.key }}
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import useProjectsStore from '@/stores/projects';
import router from '@/router';
export default defineComponent({
setup() {
const projectsStore = useProjectsStore();
return {
project: projectsStore.currentProject,
projects: projectsStore.formatted,
toProject,
};
function toProject(key: string) {
router.push(`/${key}/login`);
}
},
});
</script>
<style lang="scss" scoped>
.project-chooser {
display: flex;
align-items: center;
width: max-content;
height: 64px;
cursor: pointer;
}
.public-view-logo {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
background-color: var(--brand);
border-radius: var(--border-radius);
}
.default-logo {
width: 64px;
}
.title {
margin-left: 12px;
}
.v-icon {
--v-icon-color: var(--foreground-subdued);
margin-left: 4px;
}
</style>

View File

@@ -0,0 +1,35 @@
# Project Chooser
Renders the project's logo, and allows you to switch to other projects. If there's only one project
available, it renders just the logo.
## Usage
```html
<template>
<project-chooser />
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import ProjectChooser from '@/views/public/components/project-chooser';
export default {
components: {
ProjectChooser
}
}
</script>
```
## Props
n/a
## Events
n/a
## Slots
n/a
## CSS Variables
n/a

View File

@@ -4,12 +4,19 @@ import { mount, createLocalVue, Wrapper } from '@vue/test-utils';
import VIcon from '@/components/v-icon/';
import { useProjectsStore } from '@/stores/projects/';
import { ProjectWithKey } from '@/stores/projects/types';
import Tooltip from '@/directives/tooltip/tooltip';
import ClickOutside from '@/directives/click-outside';
import VMenu from '@/components/v-menu';
import VList, { VListItem, VListItemIcon, VListItemContent } from '@/components/v-list';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-icon', VIcon);
localVue.directive('tooltip', Tooltip);
localVue.component('v-list', VList);
localVue.component('v-list-item', VListItem);
localVue.component('v-list-item-icon', VListItemIcon);
localVue.component('v-list-item-content', VListItemContent);
localVue.component('v-menu', VMenu);
localVue.directive('click-outside', ClickOutside);
import PublicView from './public-view.vue';
@@ -48,6 +55,7 @@ const mockProject: ProjectWithKey = {
php_api: 'fpm-fcgi',
},
},
authenticated: true,
};
describe('Views / Public', () => {
@@ -98,6 +106,7 @@ describe('Views / Public', () => {
code: 250,
message: 'Test error',
},
authenticated: false,
},
];
store.state.currentProjectKey = 'my-project';

View File

@@ -1,7 +1,7 @@
<template>
<div class="public-view">
<div class="container">
<public-view-logo :version="version" />
<project-chooser />
<div class="content" :class="{ wide }">
<slot />
</div>
@@ -9,19 +9,30 @@
<slot name="notice" />
</div>
</div>
<div class="art" :style="artStyles"></div>
<div class="art" :style="artStyles">
<transition name="scale">
<img
class="foreground"
v-if="project && project.foregroundImage"
:src="project.foregroundImage"
:alt="project.name"
/>
</transition>
<div class="note" v-if="project && project.note" v-html="marked(project.note)" />
</div>
</div>
</template>
<script lang="ts">
import { version } from '../../../package.json';
import { defineComponent, computed } from '@vue/composition-api';
import PublicViewLogo from './components/logo/';
import ProjectChooser from './components/project-chooser/';
import { useProjectsStore } from '@/stores/projects/';
import marked from 'marked';
export default defineComponent({
components: {
PublicViewLogo,
ProjectChooser,
},
props: {
wide: {
@@ -35,23 +46,19 @@ export default defineComponent({
const backgroundStyles = computed<string>(() => {
const defaultColor = '#263238';
let currentProject = projectsStore.currentProject.value;
if (currentProject === null) {
if (projectsStore.currentProject.value === null) {
return defaultColor;
}
if (currentProject.error !== undefined) {
if (projectsStore.currentProject.value.error) {
return defaultColor;
}
currentProject = currentProject;
if (currentProject.api?.project_background?.full_url) {
return `url(${currentProject.api?.project_background.full_url})`;
if (projectsStore.currentProject.value.backgroundImage) {
return `url(${projectsStore.currentProject.value.backgroundImage})`;
}
return currentProject.api?.project_color || defaultColor;
return projectsStore.currentProject.value.color || defaultColor;
});
const artStyles = computed(() => ({
@@ -60,7 +67,7 @@ export default defineComponent({
backgroundPosition: 'center center',
}));
return { version, artStyles };
return { version, artStyles, marked, project: projectsStore.currentProject };
},
});
</script>
@@ -97,14 +104,35 @@ export default defineComponent({
}
.art {
position: relative;
display: none;
flex-grow: 1;
align-items: center;
justify-content: center;
height: 100%;
background-position: center center;
background-size: cover;
.foreground {
max-width: 400px;
}
.note {
position: absolute;
right: 0;
bottom: 40px;
left: 0;
max-width: 340px;
margin: 0 auto;
padding: 8px 12px;
color: var(--white);
background-color: #2632383f;
border-radius: var(--border-radius);
backdrop-filter: blur(10px);
}
@include breakpoint(small) {
display: block;
display: flex;
}
}
@@ -120,4 +148,16 @@ export default defineComponent({
}
}
}
.scale-enter-active,
.scale-leave-active {
transition: all 600ms var(--transition);
}
.scale-enter,
.scale-leave-to {
position: absolute;
transform: scale(0.95);
opacity: 0;
}
</style>

View File

@@ -1869,6 +1869,11 @@
dependencies:
"@babel/types" "^7.3.0"
"@types/base-64@^0.1.3":
version "0.1.3"
resolved "https://registry.yarnpkg.com/@types/base-64/-/base-64-0.1.3.tgz#875320c0d019f576a179324124cdbd5031a411f5"
integrity sha512-DJpw7RKNMXygZ0j2xe6ROBqiJUy7JWEItkzOPBzrT35HUWS7VLYyW9XJX8yCCvE2xg8QD7wesvVyXFg8AVHTMA==
"@types/color-name@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
@@ -3633,6 +3638,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
base-64@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb"
integrity sha1-eAqZyE59YAJgNhURxId2E78k9rs=
base64-js@^1.0.2:
version "1.3.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"