mirror of
https://github.com/directus/directus.git
synced 2026-01-27 07:08:17 -05:00
Public (#373)
* 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:
@@ -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",
|
||||
|
||||
@@ -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: () => ({}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
16
src/app.vue
16
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(
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import ProjectError from './project-error.vue';
|
||||
|
||||
export { ProjectError };
|
||||
export default ProjectError;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
4
src/routes/reset-password/index.ts
Normal file
4
src/routes/reset-password/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import ResetPasswordRoute from './reset-password.vue';
|
||||
|
||||
export { ResetPasswordRoute };
|
||||
export default ResetPasswordRoute;
|
||||
90
src/routes/reset-password/request.vue
Normal file
90
src/routes/reset-password/request.vue
Normal 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>
|
||||
37
src/routes/reset-password/reset-password.vue
Normal file
37
src/routes/reset-password/reset-password.vue
Normal 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>
|
||||
95
src/routes/reset-password/reset.vue
Normal file
95
src/routes/reset-password/reset.vue
Normal 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>
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,4 +44,5 @@ export interface Project {
|
||||
|
||||
export interface ProjectWithKey extends Project {
|
||||
key: string;
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
4
src/utils/jwt-payload/index.ts
Normal file
4
src/utils/jwt-payload/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import jwtPayload from './jwt-payload';
|
||||
|
||||
export { jwtPayload };
|
||||
export default jwtPayload;
|
||||
9
src/utils/jwt-payload/jwt-payload.ts
Normal file
9
src/utils/jwt-payload/jwt-payload.ts
Normal 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;
|
||||
}
|
||||
11
src/utils/jwt-payload/readme.md
Normal file
11
src/utils/jwt-payload/readme.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# JWT Payload
|
||||
|
||||
Get the payload from a JWT token.
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
jwtPayload('eyJa ... Gm18');
|
||||
|
||||
// => { exp: 1586466920, [key: string]: any }
|
||||
```
|
||||
@@ -73,6 +73,7 @@ export const withCustomLogo = () =>
|
||||
php_api: 'fpm-fcgi',
|
||||
},
|
||||
},
|
||||
authenticated: true,
|
||||
},
|
||||
];
|
||||
projectsStore.state.currentProjectKey = 'my-project';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import PublicViewLogo from './logo.vue';
|
||||
|
||||
export { PublicViewLogo };
|
||||
export default PublicViewLogo;
|
||||
@@ -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>
|
||||
@@ -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
|
||||
4
src/views/public/components/project-chooser/index.ts
Normal file
4
src/views/public/components/project-chooser/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import ProjectChooser from './project-chooser.vue';
|
||||
|
||||
export { ProjectChooser };
|
||||
export default ProjectChooser;
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -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 />
|
||||
`,
|
||||
});
|
||||
@@ -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>
|
||||
35
src/views/public/components/project-chooser/readme.md
Normal file
35
src/views/public/components/project-chooser/readme.md
Normal 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
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
10
yarn.lock
10
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"
|
||||
|
||||
Reference in New Issue
Block a user