Add SSO flow (#595)

* Store / parse sso providers in project info

* Render sso provider links on login form

* Provide path to api on sso

* Finish SSO

* Fix user detail route

* Accept string error codes

* Rename color->type, fix old v-notices

* Add adding user string

* Fix reading order in v-menu
This commit is contained in:
Rijk van Zanten
2020-05-19 18:02:04 -04:00
committed by GitHub
parent 9a923d41af
commit 3f9d5d7d96
25 changed files with 136 additions and 50 deletions

View File

@@ -22,7 +22,7 @@
@input="$emit('input', $event)"
/>
<v-notice v-else warning>
<v-notice v-else type="warning">
{{ $t('interface_not_found', { interface: field.interface }) }}
</v-notice>
</div>

View File

@@ -103,14 +103,6 @@ export default defineComponent({
const id = computed(() => nanoid());
const popper = ref<HTMLElement>(null);
const { isActive, activate, deactivate, toggle } = useActiveState();
watch(isActive, () => {
Vue.nextTick(() => {
popper.value = document.getElementById(id.value);
});
});
const { start, stop, styles, arrowStyles, placement: popperPlacement } = usePopper(
reference,
popper,
@@ -121,6 +113,14 @@ export default defineComponent({
}))
);
const { isActive, activate, deactivate, toggle } = useActiveState();
watch(isActive, () => {
Vue.nextTick(() => {
popper.value = document.getElementById(id.value);
});
});
return {
id,
activator,
@@ -157,7 +157,7 @@ export default defineComponent({
watch(popper, async () => {
if (popper.value !== null) {
await start();
} else if (stop) {
} else {
stop();
}
});

View File

@@ -1,4 +1,4 @@
import { withKnobs, text, boolean } from '@storybook/addon-knobs';
import { withKnobs, text, select } from '@storybook/addon-knobs';
import Vue from 'vue';
import VNotice from './v-notice.vue';
import VIcon from '../v-icon/';
@@ -22,17 +22,11 @@ export const withText = () => ({
text: {
default: text('Text', 'This is a notice'),
},
success: {
default: boolean('Success', false),
},
warning: {
default: boolean('Warning', false),
},
danger: {
default: boolean('Danger', false),
type: {
default: select('Type', ['info', 'success', 'warning', 'danger'], 'info'),
},
},
template: `<v-notice :success="success" :warning="warning" :danger="danger">{{text}}</v-notice>`,
template: `<v-notice :type="type">{{text}}</v-notice>`,
});
export const withCustomColors = () => ({

View File

@@ -1,5 +1,5 @@
<template>
<div class="v-notice" :class="[color, { center }]">
<div class="v-notice" :class="[type, { center }]">
<v-icon v-if="icon !== false" :name="iconName" left />
<slot />
</div>
@@ -10,7 +10,7 @@ import { defineComponent, computed, PropType } from '@vue/composition-api';
export default defineComponent({
props: {
color: {
type: {
type: String as PropType<'normal' | 'info' | 'success' | 'warning' | 'danger'>,
default: 'normal',
},
@@ -29,13 +29,13 @@ export default defineComponent({
return props.icon;
}
if (props.color == 'info') {
if (props.type == 'info') {
return 'info';
} else if (props.color == 'success') {
} else if (props.type == 'success') {
return 'check_circle';
} else if (props.color == 'warning') {
} else if (props.type == 'warning') {
return 'warning';
} else if (props.color == 'danger') {
} else if (props.type == 'danger') {
return 'error';
} else {
return 'info';

View File

@@ -1,5 +1,5 @@
<template>
<v-notice v-if="!choices" warning>
<v-notice v-if="!choices" type="warning">
{{ $t('choices_option_configured_incorrectly') }}
</v-notice>
<div

View File

@@ -1,5 +1,5 @@
<template>
<v-notice v-if="!choices" warning>
<v-notice v-if="!choices" type="warning">
{{ $t('choices_option_configured_incorrectly') }}
</v-notice>
<v-select

View File

@@ -1,5 +1,5 @@
<template>
<v-notice v-if="!choices" warning>
<v-notice v-if="!choices" type="warning">
{{ $t('choices_option_configured_incorrectly') }}
</v-notice>
<v-select

View File

@@ -1,8 +1,8 @@
<template>
<v-notice warning v-if="!relation">
<v-notice type="warning" v-if="!relation">
{{ $t('relationship_not_setup') }}
</v-notice>
<v-notice warning v-else-if="!displayTemplate">
<v-notice type="warning" v-else-if="!displayTemplate">
{{ $t('display_template_not_setup') }}
</v-notice>
<div class="many-to-one" v-else>

View File

@@ -1,5 +1,5 @@
<template>
<v-notice :icon="icon" :color="color">
<v-notice :icon="icon" :type="color">
<div v-html="marked(text)" />
</v-notice>
</template>
@@ -23,7 +23,7 @@ export default defineComponent({
default: 'No text configured...',
},
},
setup(props) {
setup() {
return { marked };
},
});

View File

@@ -1,5 +1,5 @@
<template>
<v-notice warning v-if="!relation">
<v-notice type="warning" v-if="!relation">
{{ $t('relationship_not_setup') }}
</v-notice>
<div class="one-to-many" v-else>

View File

@@ -1,5 +1,5 @@
<template>
<v-notice v-if="!choices" warning>
<v-notice v-if="!choices" type="warning">
{{ $t('choices_option_configured_incorrectly') }}
</v-notice>
<div

View File

@@ -525,6 +525,8 @@
"other": "Other...",
"adding_user": "Adding User",
"statuses_not_configured": "Status mapping option configured incorrectly",
"status_mapping": "Status Mapping",

View File

@@ -107,13 +107,14 @@ export function translateAPIError(error: RequestError | number) {
let code = error;
if (typeof error !== 'number') {
if (typeof error === 'object') {
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

@@ -1,5 +1,5 @@
<template>
<private-view :title="loading ? $t('loading') : item.first_name + ' ' + item.last_name">
<private-view :title="title">
<template #title-outer:prepend>
<v-button class="header-icon" rounded icon secondary exact :to="breadcrumb[0].to">
<v-icon name="arrow_back" />
@@ -137,7 +137,19 @@ export default defineComponent({
const confirmDelete = ref(false);
const title = computed(() => {
if (loading.value === true) return i18n.t('loading');
if (isNew.value === false && item.value !== null) {
const user = item.value as any;
return `${user.first_name} ${user.last_name}`;
}
return i18n.t('adding_user');
});
return {
title,
item,
loading,
error,

View File

@@ -46,7 +46,7 @@ export const defaultRoutes: RouteConfig[] = [
path: '/:project/login',
component: LoginRoute,
props: (route) => ({
ssoErrorCode: route.query.error,
ssoErrorCode: route.query.error ? route.query.code : null,
}),
meta: {
public: true,

View File

@@ -9,14 +9,14 @@
<v-progress-linear v-if="loading" indeterminate />
<template v-else>
<v-notice danger v-if="error">
<v-notice type="danger" v-if="error">
{{ errorFormatted }}
</v-notice>
<template v-else>{{ $t('creating_project_success_copy') }}</template>
<template v-if="first">
<v-notice warning>
<v-notice type="warning">
{{ $t('creating_project_success_super_admin_password') }}
</v-notice>

View File

@@ -9,8 +9,7 @@
v-else
v-for="requirement in requirements"
:key="requirement.key"
:success="requirement.success"
:warning="requirement.success === false"
:type="requirement.success ? 'success' : 'warning'"
>
{{ requirement.value }}
</v-notice>

View File

@@ -13,7 +13,7 @@
v-model="password"
:placeholder="$t('password')"
/>
<v-notice danger v-if="error">
<v-notice type="warning" v-if="error">
{{ errorFormatted }}
</v-notice>
<div class="buttons">
@@ -22,6 +22,26 @@
{{ $t('forgot_password') }}
</router-link>
</div>
<template v-if="ssoProviders">
<v-divider class="sso-divider" />
<v-button
secondary
class="sso-button"
rounded
icon
v-for="provider in ssoProviders"
:key="provider.name"
:href="provider.link"
>
<img :src="provider.icon" :alt="provider.name" />
</v-button>
<v-notice class="sso-notice" type="danger" v-if="ssoError">
{{ translateAPIError(ssoError) }}
</v-notice>
</template>
</form>
</template>
@@ -32,8 +52,15 @@ import { useProjectsStore } from '@/stores/projects';
import { login } from '@/auth';
import { RequestError } from '@/api';
import { translateAPIError } from '@/lang';
import getRootPath from '@/utils/get-root-path';
export default defineComponent({
props: {
ssoError: {
type: String,
default: null,
},
},
setup() {
const projectsStore = useProjectsStore();
@@ -53,7 +80,30 @@ export default defineComponent({
return `/${projectsStore.state.currentProjectKey}/reset-password`;
});
return { errorFormatted, error, email, password, onSubmit, loggingIn, forgotLink };
const ssoProviders = computed(() => {
const redirectURL =
getRootPath() + `admin/${projectsStore.state.currentProjectKey}/login`;
return projectsStore.currentProject.value.sso.map(
(provider: { icon: string; name: string }) => {
return {
...provider,
link: `/${projectsStore.state.currentProjectKey}/auth/sso/${provider.name}?mode=cookie&redirect_url=${redirectURL}`,
};
}
);
});
return {
ssoProviders,
errorFormatted,
error,
email,
password,
onSubmit,
loggingIn,
forgotLink,
translateAPIError,
};
async function onSubmit() {
if (email.value === null || password.value === null) return;
@@ -98,4 +148,19 @@ export default defineComponent({
color: var(--foreground-normal);
}
}
.sso-divider {
margin: 24px 0;
}
.sso-button {
img {
width: 24px;
height: auto;
}
}
.sso-notice {
margin-top: 24px;
}
</style>

View File

@@ -3,10 +3,10 @@
<h1 class="type-title">{{ $t('sign_in') }}</h1>
<continue-as v-if="currentProject.authenticated" />
<v-notice danger v-else-if="currentProject && currentProject.error">
<v-notice type="danger" v-else-if="currentProject && currentProject.error">
{{ errorFormatted }}
</v-notice>
<login-form v-else />
<login-form v-else :sso-error="ssoErrorCode" />
<template #notice>
<v-icon name="lock_outlined" left />
@@ -24,6 +24,12 @@ import useProjectsStore from '../../stores/projects';
import { translateAPIError } from '@/lang';
export default defineComponent({
props: {
ssoErrorCode: {
type: String,
default: null,
},
},
components: { LoginForm, ContinueAs },
setup() {
const projectsStore = useProjectsStore();

View File

@@ -7,8 +7,8 @@
v-model="email"
:placeholder="$t('email')"
/>
<v-notice success v-if="done">{{ $t('password_reset_sent') }}</v-notice>
<v-notice danger v-if="error">
<v-notice type="success" v-if="done">{{ $t('password_reset_sent') }}</v-notice>
<v-notice type="danger" v-if="error">
{{ errorFormatted }}
</v-notice>
<div class="buttons">

View File

@@ -9,8 +9,8 @@
v-model="password"
:disabled="done"
/>
<v-notice success v-if="done">{{ $t('password_reset_successful') }}</v-notice>
<v-notice danger v-if="error">
<v-notice type="success" v-if="done">{{ $t('password_reset_successful') }}</v-notice>
<v-notice type="danger" v-if="error">
{{ errorFormatted }}
</v-notice>
<v-button v-if="!done" type="submit" :loading="resetting" large>{{ $t('reset') }}</v-button>

View File

@@ -28,6 +28,7 @@ export const useProjectsStore = createStore({
logo: project?.api?.project_logo?.full_url || null,
color: project?.api?.project_color || null,
note: project?.api?.project_public_note || null,
sso: project?.api?.sso || [],
};
});
},

View File

@@ -27,6 +27,10 @@ export interface Project {
default_locale: string;
telemetry: boolean;
project_name: string;
sso: {
name: string;
icon: string;
}[];
};
server?: {
max_upload_size: number;

View File

@@ -65,6 +65,7 @@ export const withCustomLogo = () =>
telemetry: true,
default_locale: 'en-US',
project_public_note: null,
sso: [],
},
server: {
max_upload_size: 104857600,

View File

@@ -49,6 +49,7 @@ const mockProject: ProjectWithKey = {
default_locale: 'en-US',
project_public_note:
'**Welcome to the Directus Public Demo!**\n\nYou can sign in with `admin@example.com` and `password`. Occasionally users break things, but dont worry… the whole server resets each hour.',
sso: [],
},
server: {
max_upload_size: 104857600,