diff --git a/package.json b/package.json index 116aac7a75..167bc8d281 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api.test.ts b/src/api.test.ts index cd2484d0df..88de140ea2 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -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: () => ({}), diff --git a/src/api.ts b/src/api.ts index 02cc70fcab..5a5343c4dd 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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); diff --git a/src/app.vue b/src/app.vue index 2e39ee2937..5d416a6168 100644 --- a/src/app.vue +++ b/src/app.vue @@ -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( diff --git a/src/auth.ts b/src/auth.ts index 6f58f1ce1e..32c9bed66e 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -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`, diff --git a/src/components/v-notice/v-notice.vue b/src/components/v-notice/v-notice.vue index e6de0b2b78..2f68a10d83 100644 --- a/src/components/v-notice/v-notice.vue +++ b/src/components/v-notice/v-notice.vue @@ -63,7 +63,7 @@ export default defineComponent({ diff --git a/src/hydrate.ts b/src/hydrate.ts index 15b5af7019..11d44a4edd 100644 --- a/src/hydrate.ts +++ b/src/hydrate.ts @@ -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[]; diff --git a/src/lang/en-US/index.json b/src/lang/en-US/index.json index 409a3edbfc..fa131d4b27 100644 --- a/src/lang/en-US/index.json +++ b/src/lang/en-US/index.json @@ -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", diff --git a/src/lang/index.ts b/src/lang/index.ts index 1788796b29..5cf01c6431 100644 --- a/src/lang/index.ts +++ b/src/lang/index.ts @@ -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 { } 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); +} diff --git a/src/router.ts b/src/router.ts index 6c0c70ed0f..a60505ce10 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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`); } } diff --git a/src/routes/login/components/continue-as/continue-as.vue b/src/routes/login/components/continue-as/continue-as.vue index 5900d43035..3e1d2f3d96 100644 --- a/src/routes/login/components/continue-as/continue-as.vue +++ b/src/routes/login/components/continue-as/continue-as.vue @@ -1,45 +1,66 @@ - - - - {{ $t('sign_out') }} - - {{ $t('continue') }} - + + + + + + {{ $t('sign_out') }} + + {{ $t('continue') }} + + diff --git a/src/routes/login/components/login-form/login-form.vue b/src/routes/login/components/login-form/login-form.vue index 88b9516444..a70de866e9 100644 --- a/src/routes/login/components/login-form/login-form.vue +++ b/src/routes/login/components/login-form/login-form.vue @@ -15,15 +15,23 @@ :placeholder="$t('password')" full-width /> - {{ $t('sign_in') }} + + {{ errorFormatted }} + + + {{ $t('sign_in') }} + {{ $t('forgot_password') }} + diff --git a/src/routes/login/components/project-error/index.ts b/src/routes/login/components/project-error/index.ts deleted file mode 100644 index 8ada6cf6db..0000000000 --- a/src/routes/login/components/project-error/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import ProjectError from './project-error.vue'; - -export { ProjectError }; -export default ProjectError; diff --git a/src/routes/login/components/project-error/project-error.vue b/src/routes/login/components/project-error/project-error.vue deleted file mode 100644 index 413857f482..0000000000 --- a/src/routes/login/components/project-error/project-error.vue +++ /dev/null @@ -1,20 +0,0 @@ - - [{{ props.status }}] {{ props.error }} - - - diff --git a/src/routes/login/login.vue b/src/routes/login/login.vue index 5b07262dbc..5e1368f4f6 100644 --- a/src/routes/login/login.vue +++ b/src/routes/login/login.vue @@ -2,12 +2,10 @@ {{ $t('sign_in') }} - - + + + {{ errorFormatted }} + @@ -19,31 +17,32 @@ diff --git a/src/routes/reset-password/index.ts b/src/routes/reset-password/index.ts new file mode 100644 index 0000000000..fa5c103fcf --- /dev/null +++ b/src/routes/reset-password/index.ts @@ -0,0 +1,4 @@ +import ResetPasswordRoute from './reset-password.vue'; + +export { ResetPasswordRoute }; +export default ResetPasswordRoute; diff --git a/src/routes/reset-password/request.vue b/src/routes/reset-password/request.vue new file mode 100644 index 0000000000..40f1732424 --- /dev/null +++ b/src/routes/reset-password/request.vue @@ -0,0 +1,90 @@ + + + + {{ $t('password_reset_sent') }} + + {{ errorFormatted }} + + + {{ $t('reset') }} + {{ $t('sign_in') }} + + + + + + + diff --git a/src/routes/reset-password/reset-password.vue b/src/routes/reset-password/reset-password.vue new file mode 100644 index 0000000000..38b008246d --- /dev/null +++ b/src/routes/reset-password/reset-password.vue @@ -0,0 +1,37 @@ + + + {{ $t('reset_password') }} + + + + + + + {{ $t('not_authenticated') }} + + + + + + + diff --git a/src/routes/reset-password/reset.vue b/src/routes/reset-password/reset.vue new file mode 100644 index 0000000000..96567cf09d --- /dev/null +++ b/src/routes/reset-password/reset.vue @@ -0,0 +1,95 @@ + + + + + {{ $t('password_reset_successful') }} + + {{ errorFormatted }} + + {{ $t('reset') }} + {{ $t('sign_in') }} + + + + + + diff --git a/src/stores/projects/projects.test.ts b/src/stores/projects/projects.test.ts index a4a2697a8d..174f1c5ed9 100644 --- a/src/stores/projects/projects.test.ts +++ b/src/stores/projects/projects.test.ts @@ -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, + }, ]); }); }); diff --git a/src/stores/projects/projects.ts b/src/stores/projects/projects.ts index 65b831c507..f06f1877d2 100644 --- a/src/stores/projects/projects.ts +++ b/src/stores/projects/projects.ts @@ -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, }); } } diff --git a/src/stores/projects/types.ts b/src/stores/projects/types.ts index e9cc121169..a97708fbd6 100644 --- a/src/stores/projects/types.ts +++ b/src/stores/projects/types.ts @@ -44,4 +44,5 @@ export interface Project { export interface ProjectWithKey extends Project { key: string; + authenticated: boolean; } diff --git a/src/stores/user/user.ts b/src/stores/user/user.ts index e5b44f62e7..7cf9540ed5 100644 --- a/src/stores/user/user.ts +++ b/src/stores/user/user.ts @@ -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(); diff --git a/src/utils/jwt-payload/index.ts b/src/utils/jwt-payload/index.ts new file mode 100644 index 0000000000..1d9dff284e --- /dev/null +++ b/src/utils/jwt-payload/index.ts @@ -0,0 +1,4 @@ +import jwtPayload from './jwt-payload'; + +export { jwtPayload }; +export default jwtPayload; diff --git a/src/utils/jwt-payload/jwt-payload.ts b/src/utils/jwt-payload/jwt-payload.ts new file mode 100644 index 0000000000..c98f3c7cff --- /dev/null +++ b/src/utils/jwt-payload/jwt-payload.ts @@ -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; +} diff --git a/src/utils/jwt-payload/readme.md b/src/utils/jwt-payload/readme.md new file mode 100644 index 0000000000..39a91fda84 --- /dev/null +++ b/src/utils/jwt-payload/readme.md @@ -0,0 +1,11 @@ +# JWT Payload + +Get the payload from a JWT token. + +## Usage + +```js +jwtPayload('eyJa ... Gm18'); + +// => { exp: 1586466920, [key: string]: any } +``` diff --git a/src/views/private/components/module-bar-logo/module-bar-logo.story.ts b/src/views/private/components/module-bar-logo/module-bar-logo.story.ts index 5afd90b98e..89fe0f3083 100644 --- a/src/views/private/components/module-bar-logo/module-bar-logo.story.ts +++ b/src/views/private/components/module-bar-logo/module-bar-logo.story.ts @@ -73,6 +73,7 @@ export const withCustomLogo = () => php_api: 'fpm-fcgi', }, }, + authenticated: true, }, ]; projectsStore.state.currentProjectKey = 'my-project'; diff --git a/src/views/private/components/project-chooser/project-chooser.vue b/src/views/private/components/project-chooser/project-chooser.vue index 413c53272e..430b50e311 100644 --- a/src/views/private/components/project-chooser/project-chooser.vue +++ b/src/views/private/components/project-chooser/project-chooser.vue @@ -1,7 +1,7 @@ - {{ currentProject.api.project_name }} + {{ currentProject.name }} @@ -17,7 +17,7 @@ diff --git a/src/views/public/components/logo/index.ts b/src/views/public/components/logo/index.ts deleted file mode 100644 index 0c87560ae2..0000000000 --- a/src/views/public/components/logo/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import PublicViewLogo from './logo.vue'; - -export { PublicViewLogo }; -export default PublicViewLogo; diff --git a/src/views/public/components/logo/logo.vue b/src/views/public/components/logo/logo.vue deleted file mode 100644 index a90d9fc6c0..0000000000 --- a/src/views/public/components/logo/logo.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/src/views/public/components/logo/readme.md b/src/views/public/components/logo/readme.md deleted file mode 100644 index d2e62058ee..0000000000 --- a/src/views/public/components/logo/readme.md +++ /dev/null @@ -1,36 +0,0 @@ -# Public View Logo - -Renders the Directus logo and shows the current version of Directus on hover. - -## Usage - -```html - - - - - -``` - -## Props -| Prop | Description | Default | -|-----------|---------------------------------|---------| -| `version` | The version to display on hover | -- | - -## Events -n/a - -## Slots -n/a - -## CSS Variables -n/a diff --git a/src/views/public/components/project-chooser/index.ts b/src/views/public/components/project-chooser/index.ts new file mode 100644 index 0000000000..02de24a344 --- /dev/null +++ b/src/views/public/components/project-chooser/index.ts @@ -0,0 +1,4 @@ +import ProjectChooser from './project-chooser.vue'; + +export { ProjectChooser }; +export default ProjectChooser; diff --git a/src/views/public/components/logo/logo-dark.svg b/src/views/public/components/project-chooser/logo-dark.svg similarity index 100% rename from src/views/public/components/logo/logo-dark.svg rename to src/views/public/components/project-chooser/logo-dark.svg diff --git a/src/views/public/components/logo/logo.story.ts b/src/views/public/components/project-chooser/project-chooser.story.ts similarity index 75% rename from src/views/public/components/logo/logo.story.ts rename to src/views/public/components/project-chooser/project-chooser.story.ts index fc7f552ca7..13e3c29fe2 100644 --- a/src/views/public/components/logo/logo.story.ts +++ b/src/views/public/components/project-chooser/project-chooser.story.ts @@ -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: ` - - `, + + `, }); diff --git a/src/views/public/components/project-chooser/project-chooser.vue b/src/views/public/components/project-chooser/project-chooser.vue new file mode 100644 index 0000000000..812c18d547 --- /dev/null +++ b/src/views/public/components/project-chooser/project-chooser.vue @@ -0,0 +1,85 @@ + + + + + + + + + {{ project && (project.name || project.key) }} + + + + + + + + + {{ availableProject.name || availableProject.key }} + + + + + + + + + diff --git a/src/views/public/components/project-chooser/readme.md b/src/views/public/components/project-chooser/readme.md new file mode 100644 index 0000000000..772f6ca851 --- /dev/null +++ b/src/views/public/components/project-chooser/readme.md @@ -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 + + + + + +``` + +## Props +n/a + +## Events +n/a + +## Slots +n/a + +## CSS Variables +n/a diff --git a/src/views/public/public-view.test.ts b/src/views/public/public-view.test.ts index 73ce808697..e4f71492d1 100644 --- a/src/views/public/public-view.test.ts +++ b/src/views/public/public-view.test.ts @@ -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'; diff --git a/src/views/public/public-view.vue b/src/views/public/public-view.vue index 559685c7d8..798ddfdeb5 100644 --- a/src/views/public/public-view.vue +++ b/src/views/public/public-view.vue @@ -1,7 +1,7 @@ - + @@ -9,19 +9,30 @@ - + + + + + + @@ -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; +} diff --git a/yarn.lock b/yarn.lock index bb2089afad..0803de483a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"