Add ability to share items with people outside the platform (#10663)

* Add directus_shares

* Don't check for usage limit on refresh

* Add all endpoints to the shares controller

* Move route `/auth/shared` to `/shared/auth`

* Add password protection

* Add `share` action in permissions

* Add `shares/:pk/info`

* Start on shared-view

* Add basic styling for full shared view

* Fixed migrations

* Add inline style for shared view

* Allow title override

* Finish /info endpoint for shares

* Add basic UUID validation to share/info endpont

* Add UUID validation to other routes

* Add not found state

* Cleanup /extract/finish share login endpoint

* Cleanup auth

* Added `share_start` and `share_end`

* Add share sidebar details.

* Allow share permissions configuration

* Hide the `new_share` button for unauthorized users

* Fix uses_left displayed value

* Show expired / upcoming shares

* Improved expired/upcoming styling

* Fixed share login query

* Fix check-ip and get-permissions middlewares behaviour when role is null

* Simplify cache key

* Fix typescript linting issues

* Handle app auth flow for shared page

* Fixed /users/me response

* Show when user is authenticated

* Try showing item drawer in shared page

* Improved shared card styling

* Add shares permissions and change share card styling

* Pull in schema/permissions on share

* Create getPermissionForShare file

* Change getPermissionsForShare signature

* Render form + item on share after auth

* Finalize public front end

* Handle fake o2m field in applyQuery

* [WIP]

* New translations en-US.yaml (Bulgarian) (#10585)

* smaller label height (#10587)

* Update to the latest Material Icons (#10573)

The icons are based on https://fonts.google.com/icons

* New translations en-US.yaml (Arabic) (#10593)

* New translations en-US.yaml (Arabic) (#10594)

* New translations en-US.yaml (Portuguese, Brazilian) (#10604)

* New translations en-US.yaml (French) (#10605)

* New translations en-US.yaml (Italian) (#10613)

* fix M2A list not updating (#10617)

* Fix filters

* Add admin filter on m2o role selection

* Add admin filter on m2o role selection

* Add o2m permissions traversing

* Finish relational tree permissions generation

* Handle implicit a2o relation

* Update implicit relation regex

* Fix regex

* Fix implicitRelation unnesting for new regex

* Fix implicitRelation length check

* Rename m2a to a2o internally

* Add auto-gen permissions for a2o

* [WIP] Improve share UX

* Add ctx menu options

* Add share dialog

* Add email notifications

* Tweak endpoint

* Tweak file interface disabled state

* Add nicer invalid state to password input

* Dont return info for expired/upcoming shares

* Tweak disabled state for relational interfaces

* Fix share button for non admin roles

* Show/hide edit/delete based on permissions to shares

* Fix imports of mutationtype

* Resolve (my own) suggestions

* Fix migration for ms sql

* Resolve last suggestion

Co-authored-by: Oreilles <oreilles.github@nitoref.io>
Co-authored-by: Oreilles <33065839+oreilles@users.noreply.github.com>
Co-authored-by: Ben Haynes <ben@rngr.org>
Co-authored-by: Thien Nguyen <72242664+tatthien@users.noreply.github.com>
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
This commit is contained in:
Rijk van Zanten
2021-12-23 18:51:59 -05:00
committed by GitHub
parent d947c4f962
commit dbf35a1736
89 changed files with 2422 additions and 376 deletions

View File

@@ -6,17 +6,30 @@ import { RouteLocationRaw } from 'vue-router';
import { idleTracker } from './idle';
import { DEFAULT_AUTH_PROVIDER } from '@/constants';
export type LoginCredentials = {
type LoginCredentials = {
identifier?: string;
email?: string;
password: string;
password?: string;
otp?: string;
share?: string;
};
export async function login(credentials: LoginCredentials, provider: string): Promise<void> {
type LoginParams = {
credentials: LoginCredentials;
provider?: string;
share?: boolean;
};
function getAuthEndpoint(provider?: string, share?: boolean) {
if (share) return '/shares/auth';
if (provider === DEFAULT_AUTH_PROVIDER) return '/auth/login';
return `/auth/login/${provider}`;
}
export async function login({ credentials, provider, share }: LoginParams): Promise<void> {
const appStore = useAppStore();
const response = await api.post<any>(provider !== DEFAULT_AUTH_PROVIDER ? `/auth/login/${provider}` : '/auth/login', {
const response = await api.post<any>(getAuthEndpoint(provider, share), {
...credentials,
mode: 'cookie',
});
@@ -27,7 +40,7 @@ export async function login(credentials: LoginCredentials, provider: string): Pr
api.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
// Refresh the token 10 seconds before the access token expires. This means the user will stay
// logged in without any noticable hickups or delays
// logged in without any noticeable hiccups or delays
// setTimeout breaks with numbers bigger than 32bits. This ensures that we don't try refreshing
// for tokens that last > 24 days. Ref #4054

View File

@@ -10,6 +10,7 @@ type UsablePermissions = {
saveAllowed: ComputedRef<boolean>;
archiveAllowed: ComputedRef<boolean>;
updateAllowed: ComputedRef<boolean>;
shareAllowed: ComputedRef<boolean>;
fields: ComputedRef<Field[]>;
revisionsAllowed: ComputedRef<boolean>;
};
@@ -32,6 +33,8 @@ export function usePermissions(collection: Ref<string>, item: Ref<any>, isNew: R
const updateAllowed = computed(() => isAllowed(collection.value, 'update', item.value));
const shareAllowed = computed(() => isAllowed(collection.value, 'share', item.value));
const archiveAllowed = computed(() => {
if (!collectionInfo.value?.meta?.archive_field) return false;
@@ -90,5 +93,5 @@ export function usePermissions(collection: Ref<string>, item: Ref<any>, isNew: R
);
});
return { deleteAllowed, saveAllowed, archiveAllowed, updateAllowed, fields, revisionsAllowed };
return { deleteAllowed, saveAllowed, archiveAllowed, updateAllowed, shareAllowed, fields, revisionsAllowed };
}

View File

@@ -44,7 +44,9 @@ export function useStores(
return stores.map((useStore) => useStore()) as GenericStore[];
}
export async function hydrate(stores = useStores()): Promise<void> {
export async function hydrate(): Promise<void> {
const stores = useStores();
const appStore = useAppStore();
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();

View File

@@ -8,6 +8,7 @@
v-else
clickable
readonly
:disabled="disabled"
:placeholder="t('no_file_selected')"
:model-value="file && file.title"
@click="toggle"
@@ -84,6 +85,7 @@
collection="directus_files"
:primary-key="file.id"
:edits="edits"
:disabled="disabled"
@input="stageEdits"
/>

View File

@@ -68,7 +68,7 @@
</draggable>
</v-list>
<div class="buttons">
<div v-if="!disabled" class="buttons">
<v-menu v-if="enableCreate" show-arrow>
<template #activator="{ toggle }">
<v-button @click="toggle">

View File

@@ -165,12 +165,13 @@ export default defineComponent({
const { relation, relatedCollection, relatedPrimaryKeyField } = useRelation();
const templateWithDefaults = computed(
() =>
const templateWithDefaults = computed(() => {
return (
props.template ||
relatedCollection.value.meta?.display_template ||
`{{${fieldsStore.getPrimaryKeyFieldForCollection(relation.value.collection).field}}}`
);
`{{${fieldsStore.getPrimaryKeyFieldForCollection(relation.value.collection)?.field ?? 'id'}}}`
);
});
const fields = computed(() =>
adjustFieldsForDisplays(getFieldsFromTemplate(templateWithDefaults.value), relatedCollection.value.collection)

View File

@@ -32,6 +32,7 @@ tile_size: Tile Size
edit_field: Edit Field
conditions: Conditions
maps: Maps
switch_user: Switch User
item_creation: Item Creation
item_revision: Item Revision
enter_a_name: Enter a Name...
@@ -775,6 +776,7 @@ adding_user: Adding User
unknown_user: Unknown User
creating_in: 'Creating Item in {collection}'
editing_in: 'Editing Item in {collection}'
viewing_in: 'Viewing Item in {collection}'
creating_unit: 'Creating {unit}'
editing_unit: 'Editing {unit}'
editing_in_batch: 'Batch Editing {count} Items'
@@ -1136,6 +1138,26 @@ start_collapsed: Start Collapsed
block: Block
inline: Inline
comment: Comment
shares: Shares
unlimited_usage: Unlimited usage
uses_left: No uses left | 1 use left | {n} uses left
no_shares: No shares to show
new_share: New Share
expired: Expired
upcoming: Upcoming
share: Share
share_item: Share Item
shared_with_you: An item has been shared with you
shared_enter_passcode: Enter your passcode to continue...
shared_times_remaining: This link can only be used {n} times
shared_last_remaining: This link can only be used once
shared_uses_left: For added security, this shared page has been configured with a limited number of views. There are currently {n} views remaining, after which, the page will no longer be accessible.
share_access_page: Access Shared Page
share_access_not_found: This shared page either does not exist or has already exceeded the maximum number of allowed uses.
share_access_not_found_desc: Please contact an authorized user of this project to request access.
share_access_not_found_title: Unknown Share Page
share_copy_link: Copy Link
share_send_link: Send Link
relational_triggers: Relational Triggers
referential_action_field_label_m2o: On Delete of {collection}...
referential_action_field_label_o2m: On Deselect of {collection}...

View File

@@ -189,6 +189,12 @@
:collection="collection"
:primary-key="internalPrimaryKey"
/>
<shares-sidebar-detail
v-if="isNew === false && internalPrimaryKey"
:collection="collection"
:primary-key="internalPrimaryKey"
:allowed="shareAllowed"
/>
</template>
</private-view>
</template>
@@ -202,6 +208,7 @@ import ContentNotFound from './not-found.vue';
import { useCollection } from '@directus/shared/composables';
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
import CommentsSidebarDetail from '@/views/private/components/comments-sidebar-detail';
import SharesSidebarDetail from '@/views/private/components/shares-sidebar-detail';
import useItem from '@/composables/use-item';
import SaveOptions from '@/views/private/components/save-options';
import useShortcut from '@/composables/use-shortcut';
@@ -219,6 +226,7 @@ export default defineComponent({
ContentNotFound,
RevisionsDrawerDetail,
CommentsSidebarDetail,
SharesSidebarDetail,
SaveOptions,
},
props: {
@@ -352,11 +360,8 @@ export default defineComponent({
onBeforeRouteUpdate(editsGuard);
onBeforeRouteLeave(editsGuard);
const { deleteAllowed, archiveAllowed, saveAllowed, updateAllowed, fields, revisionsAllowed } = usePermissions(
collection,
item,
isNew
);
const { deleteAllowed, archiveAllowed, saveAllowed, updateAllowed, shareAllowed, fields, revisionsAllowed } =
usePermissions(collection, item, isNew);
const internalPrimaryKey = computed(() => {
if (isNew.value) return '+';
@@ -403,6 +408,7 @@ export default defineComponent({
archiveAllowed,
isArchived,
updateAllowed,
shareAllowed,
toggleArchive,
validationErrors,
form,

View File

@@ -128,6 +128,51 @@ export const appRecommendedPermissions: Partial<Permission>[] = [
permissions: {},
fields: ['*'],
},
{
collection: 'directus_shares',
action: 'read',
permissions: {
_or: [
{
role: {
_eq: '$CURRENT_ROLE',
},
},
{
role: {
_null: true,
},
},
],
},
fields: ['*'],
},
{
collection: 'directus_shares',
action: 'create',
permissions: {},
fields: ['*'],
},
{
collection: 'directus_shares',
action: 'update',
permissions: {
user_created: {
_eq: '$CURRENT_USER',
},
},
fields: ['*'],
},
{
collection: 'directus_shares',
action: 'delete',
permissions: {
user_created: {
_eq: '$CURRENT_USER',
},
},
fields: ['*'],
},
];
export const appMinimalPermissions: Partial<Permission>[] = [

View File

@@ -5,6 +5,7 @@
<v-icon v-tooltip="t('read')" name="visibility" />
<v-icon v-tooltip="t('update')" name="edit" outline />
<v-icon v-tooltip="t('delete_label')" name="delete" outline />
<v-icon v-tooltip="t('share')" name="share" outline />
</div>
</template>

View File

@@ -41,6 +41,14 @@
:loading="isLoading('delete')"
:app-minimal="appMinimal && appMinimal.find((p) => p.action === 'delete')"
/>
<permissions-overview-toggle
action="share"
:collection="collection"
:role="role"
:permissions="permissions"
:loading="isLoading('share')"
:app-minimal="appMinimal && appMinimal.find((p) => p.action === 'share')"
/>
</div>
</template>

View File

@@ -3,10 +3,13 @@ import { Permission, Collection } from '@directus/shared/types';
import { unexpectedError } from '@/utils/unexpected-error';
import { inject, ref, Ref } from 'vue';
const ACTIONS = ['create', 'read', 'update', 'delete', 'share'] as const;
type Action = typeof ACTIONS[number];
type UsableUpdatePermissions = {
getPermission: (action: string) => Permission | undefined;
setFullAccess: (action: 'create' | 'read' | 'update' | 'delete') => Promise<void>;
setNoAccess: (action: 'create' | 'read' | 'update' | 'delete') => Promise<void>;
setFullAccess: (action: Action) => Promise<void>;
setNoAccess: (action: Action) => Promise<void>;
setFullAccessAll: () => Promise<void>;
setNoAccessAll: () => Promise<void>;
};
@@ -25,7 +28,7 @@ export default function useUpdatePermissions(
return permissions.value.find((permission) => permission.action === action);
}
async function setFullAccess(action: 'create' | 'read' | 'update' | 'delete') {
async function setFullAccess(action: Action) {
if (saving.value === true) return;
saving.value = true;
@@ -72,7 +75,7 @@ export default function useUpdatePermissions(
}
}
async function setNoAccess(action: 'create' | 'read' | 'update' | 'delete') {
async function setNoAccess(action: Action) {
if (saving.value === true) return;
const permission = getPermission(action);
@@ -104,10 +107,8 @@ export default function useUpdatePermissions(
});
}
const actions = ['create', 'read', 'update', 'delete'];
await Promise.all(
actions.map(async (action) => {
ACTIONS.map(async (action) => {
const permission = getPermission(action);
if (permission) {
try {

View File

@@ -114,7 +114,7 @@ export default defineComponent({
const tabs = [];
if (['read', 'update', 'delete'].includes(action)) {
if (['read', 'update', 'delete', 'share'].includes(action)) {
tabs.push({
text: t('item_permissions'),
value: 'permissions',

View File

@@ -3,6 +3,7 @@ import { hydrate } from '@/hydrate';
import AcceptInviteRoute from '@/routes/accept-invite';
import LoginRoute from '@/routes/login';
import LogoutRoute from '@/routes/logout';
import ShareRoute from '@/routes/shared';
import PrivateNotFoundRoute from '@/routes/private-not-found';
import ResetPasswordRoute from '@/routes/reset-password';
import { useAppStore, useServerStore, useUserStore } from '@/stores';
@@ -50,6 +51,14 @@ export const defaultRoutes: RouteRecordRaw[] = [
public: true,
},
},
{
name: 'shared',
path: '/shared/:id([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
component: ShareRoute,
meta: {
public: true,
},
},
{
name: 'private-404',
path: '/:_(.+)+',

View File

@@ -24,6 +24,7 @@ import { hydrate } from '@/hydrate';
import { useRouter } from 'vue-router';
import { userName } from '@/utils/user-name';
import { unexpectedError } from '@/utils/unexpected-error';
import { logout } from '@/auth';
export default defineComponent({
setup() {
@@ -55,6 +56,10 @@ export default defineComponent({
},
});
if (response.data.data.share) {
await logout();
}
name.value = userName(response.data.data);
lastPage.value = response.data.data.last_page;
} catch (err: any) {

View File

@@ -101,7 +101,7 @@ export default defineComponent({
credentials.otp = otp.value;
}
await login(credentials, provider.value);
await login({ provider: provider.value, credentials });
// Stores are hydrated after login
const lastPage = userStore.currentUser?.last_page;

View File

@@ -107,7 +107,7 @@ export default defineComponent({
credentials.otp = otp.value;
}
await login(credentials, provider.value);
await login({ provider: provider.value, credentials });
const redirectQuery = router.currentRoute.value.query.redirect as string;

View File

@@ -55,9 +55,9 @@ export default defineComponent({
const appStore = useAppStore();
const providers = ref([]);
const providers = ref<{ driver: string; name: string }[]>([]);
const provider = ref(DEFAULT_AUTH_PROVIDER);
const providerOptions = ref([]);
const providerOptions = ref<{ text: string; value: string }[]>([]);
const driver = ref('local');
const providerSelect = computed({

View File

@@ -0,0 +1,36 @@
<template>
<v-form
v-model="item"
:collection="collection"
:initial-values="item"
:primary-key="primaryKey"
disabled
:loading="loading"
/>
</template>
<script lang="ts">
import { defineComponent, toRefs } from 'vue';
import { useItem } from '@/composables/use-item';
export default defineComponent({
id: 'ShareItem',
props: {
collection: {
type: String,
required: true,
},
primaryKey: {
type: String,
required: true,
},
},
setup(props) {
const { collection, primaryKey } = toRefs(props);
const { item, loading } = useItem(collection, primaryKey);
return { item, loading };
},
});
</script>

View File

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

View File

@@ -0,0 +1,235 @@
<template>
<div v-if="loading" class="hydrating">
<v-progress-circular indeterminate />
</div>
<shared-view v-else :inline="!authenticated" :title="title">
<div v-if="notFound">
<strong>{{ t('share_access_not_found') }}</strong>
{{ t('share_access_not_found_desc') }}
</div>
<v-error v-else-if="error" :error="error" />
<template v-else-if="share">
<template v-if="!authenticated">
<v-notice v-if="usesLeft !== undefined && usesLeft !== null" :type="usesLeftNoticeType">
{{ t('shared_uses_left', usesLeft) }}
</v-notice>
<template v-if="usesLeft !== 0">
<v-input
v-if="share.password"
class="password"
:class="{ invalid: passwordWrong }"
type="password"
:placeholder="t('shared_enter_passcode')"
@update:modelValue="password = $event"
/>
<v-button :busy="authenticating" @click="authenticate">
{{ t('share_access_page') }}
</v-button>
</template>
</template>
<template v-else>
<share-item :collection="share.collection" :primary-key="share.item" />
</template>
</template>
</shared-view>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { defineComponent, computed, ref } from 'vue';
import { useAppStore } from '@/stores';
import api, { RequestError } from '@/api';
import { login, logout } from '@/auth';
import { Share } from '@directus/shared/types';
import ShareItem from './components/share-item.vue';
import { hydrate } from '@/hydrate';
import { useCollection } from '@directus/shared/composables';
type ShareInfo = Pick<
Share,
'id' | 'collection' | 'item' | 'password' | 'date_start' | 'date_end' | 'max_uses' | 'times_used'
>;
export default defineComponent({
components: { ShareItem },
setup() {
const { t } = useI18n();
const appStore = useAppStore();
const authenticated = computed(() => appStore.authenticated);
const loading = ref(true);
const authenticating = ref(false);
const notFound = ref(false);
const error = ref<RequestError | null>(null);
const router = useRouter();
const route = useRoute();
const shareId = route.params.id as string;
const share = ref<ShareInfo>();
const usesLeft = ref<number | null>(null);
const usesLeftNoticeType = computed(() => {
if (!usesLeft.value) return 'info';
if (usesLeft.value < 3) return 'warning';
return 'info';
});
const password = ref<string>();
const passwordWrong = ref(false);
getShareInformation(shareId);
const { info } = useCollection(computed(() => share.value?.collection ?? null));
const collectionName = computed(() => info.value?.name);
const title = computed(() => {
if (notFound.value) return t('share_access_not_found_title');
if (collectionName.value) return t('viewing_in', { collection: collectionName.value });
return t('share_access_page');
});
return {
t,
share,
error,
title,
loading,
notFound,
password,
authenticate,
authenticated,
authenticating,
usesLeft,
usesLeftNoticeType,
collectionName,
passwordWrong,
};
async function getShareInformation(shareId: string) {
loading.value = true;
try {
const response = await api.get(`/shares/info/${shareId}`);
share.value = response.data.data;
if (!share.value) {
notFound.value = true;
loading.value = false;
return;
}
const { max_uses, times_used } = share.value;
if (max_uses) {
usesLeft.value = max_uses - times_used;
}
await handleAuth();
} catch (err: any) {
if (err.response?.status === 404 || err.response?.status === 403) {
notFound.value = true;
} else {
error.value = err;
}
} finally {
loading.value = false;
}
}
async function handleAuth() {
if (appStore.authenticated) {
const currentUser = await api.get('/users/me', { params: { fields: ['id'] } });
if (currentUser.data.data?.share) {
if (currentUser.data.data.share !== shareId) {
await logout({ navigate: false });
} else {
await hydrate();
}
}
// Logged in as regular user
if (currentUser.data.data?.id && !currentUser.data.data?.share) {
router.replace(`/content/${share.value!.collection}/${share.value!.item}`);
return;
}
}
if (!share.value?.password && !share.value?.max_uses) {
if (appStore.authenticated) {
await hydrate();
} else {
await authenticate();
}
}
}
async function authenticate() {
authenticating.value = true;
try {
const credentials = { share: shareId, password: password.value };
await login({ share: true, credentials });
} catch (err: any) {
if (err?.response?.data?.errors?.[0]?.extensions?.code === 'INVALID_CREDENTIALS') {
passwordWrong.value = true;
return;
}
error.value = err;
} finally {
authenticating.value = false;
}
}
},
});
</script>
<style lang="scss" scoped>
h2 {
margin-bottom: 20px;
}
.v-input,
.v-notice {
margin-bottom: 32px;
}
.hydrating {
position: fixed;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.password {
position: relative;
}
.password.invalid::before {
position: absolute;
top: -12px;
left: -12px;
width: calc(100% + 24px);
height: calc(100% + 24px);
background-color: var(--danger-alt);
border-radius: var(--border-radius);
transition: var(--medium) var(--transition);
transition-property: background-color, padding, margin;
content: '';
}
</style>

View File

@@ -17,18 +17,24 @@ export const useNotificationsStore = defineStore({
}),
actions: {
async hydrate() {
await this.getUnreadCount();
const userStore = useUserStore();
if (userStore.currentUser && !('share' in userStore.currentUser)) {
await this.getUnreadCount();
}
},
async getUnreadCount() {
const userStore = useUserStore();
if (!userStore.currentUser || !('id' in userStore.currentUser)) return;
const countResponse = await api.get('/notifications', {
params: {
filter: {
_and: [
{
recipient: {
_eq: userStore.currentUser!.id,
_eq: userStore.currentUser.id,
},
},
{

View File

@@ -127,8 +127,11 @@ export const usePresetsStore = defineStore({
},
actions: {
async hydrate() {
const userStore = useUserStore();
if (!userStore.currentUser || 'share' in userStore.currentUser) return;
// Hydrate is only called for logged in users, therefore, currentUser exists
const { id, role } = useUserStore().currentUser!;
const { id, role } = userStore.currentUser;
const values = await Promise.all([
// All user saved bookmarks and presets

View File

@@ -5,6 +5,7 @@ import { unexpectedError } from '@/utils/unexpected-error';
import { merge } from 'lodash';
import { defineStore } from 'pinia';
import { Settings } from '@directus/shared/types';
import { useUserStore } from './user';
export const useSettingsStore = defineStore({
id: 'settingsStore',
@@ -13,6 +14,9 @@ export const useSettingsStore = defineStore({
}),
actions: {
async hydrate() {
const userStore = useUserStore();
if (!userStore.currentUser || 'share' in userStore.currentUser) return;
const response = await api.get(`/settings`);
this.settings = response.data.data;
},

View File

@@ -4,16 +4,25 @@ import { User } from '@directus/shared/types';
import { userName } from '@/utils/user-name';
import { defineStore } from 'pinia';
type ShareUser = {
share: string;
role: {
id: string;
admin_access: false;
app_access: false;
};
};
export const useUserStore = defineStore({
id: 'userStore',
state: () => ({
currentUser: null as User | null,
currentUser: null as User | ShareUser | null,
loading: false,
error: null,
}),
getters: {
fullName(): string | null {
if (this.currentUser === null) return null;
if (this.currentUser === null || 'share' in this.currentUser) return null;
return userName(this.currentUser);
},
isAdmin(): boolean {
@@ -25,11 +34,18 @@ export const useUserStore = defineStore({
this.loading = true;
try {
const { data } = await api.get(`/users/me`, {
params: {
fields: '*,avatar.id,role.*',
},
});
const fields = [
'id',
'language',
'last_page',
'theme',
'avatar.id',
'role.admin_access',
'role.app_access',
'role.id',
];
const { data } = await api.get(`/users/me`, { params: { fields } });
this.currentUser = data.data;
} catch (error: any) {
@@ -57,7 +73,7 @@ export const useUserStore = defineStore({
latency: end - start,
});
if (this.currentUser) {
if (this.currentUser && !('share' in this.currentUser)) {
this.currentUser.last_page = page;
}
},

View File

@@ -3,3 +3,4 @@ export * from './error';
export * from './insights';
export * from './notifications';
export * from './login';
export * from './shares';

View File

@@ -20,13 +20,13 @@ export function isAllowed(
);
if (!permissionInfo) return false;
if (!permissionInfo.fields) return false;
if (!permissionInfo.fields && action !== 'share') return false;
if (strict && permissionInfo.fields.includes('*') === false && value) {
if (strict && action !== 'share' && permissionInfo.fields!.includes('*') === false && value) {
const allowedFields = permissionInfo.fields;
const attemptedFields = Object.keys(value);
if (attemptedFields.every((field) => allowedFields.includes(field)) === false) return false;
if (attemptedFields.every((field) => allowedFields!.includes(field)) === false) return false;
}
const schema = generateJoi(permissionInfo.permissions, {

View File

@@ -2,6 +2,10 @@ import { i18n } from '@/lang';
import { User } from '@directus/shared/types';
export function userName(user: Partial<User>): string {
if (!user) {
return i18n.global.t('unknown_user') as string;
}
if (user.first_name && user.last_name) {
return `${user.first_name} ${user.last_name}`;
}

View File

@@ -32,6 +32,7 @@
/>
<v-form
:disabled="disabled"
:loading="loading"
:initial-values="item && item[junctionField]"
:primary-key="relatedPrimaryKey"
@@ -46,6 +47,7 @@
<v-form
v-model="internalEdits"
:disabled="disabled"
:loading="loading"
:initial-values="item"
:primary-key="primaryKey"
@@ -82,7 +84,7 @@ export default defineComponent({
},
primaryKey: {
type: [String, Number],
required: true,
default: null,
},
edits: {
type: Object as PropType<Record<string, any>>,
@@ -92,6 +94,10 @@ export default defineComponent({
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
// There's an interesting case where the main form can be a newly created item ('+'), while
// it has a pre-selected related item it needs to alter. In that case, we have to fetch the
// related data anyway.

View File

@@ -73,9 +73,7 @@ export default defineComponent({
const signOutActive = ref(false);
const avatarURL = computed<string | null>(() => {
if (userStore.currentUser === null) return null;
if (userStore.currentUser.avatar === null) return null;
if (!userStore.currentUser || !('avatar' in userStore.currentUser) || !userStore.currentUser?.avatar) return null;
return addTokenToURL(getRootPath() + `assets/${userStore.currentUser.avatar.id}?key=system-medium-cover`);
});

View File

@@ -0,0 +1,4 @@
import SharesSidebarDetail from './shares-sidebar-detail.vue';
export { SharesSidebarDetail };
export default SharesSidebarDetail;

View File

@@ -0,0 +1,203 @@
<template>
<div class="share-item">
<div class="share-item-header">
<span class="type-label">{{ share.name }}</span>
<div class="header-right">
<v-menu show-arrow placement="bottom-end">
<template #activator="{ toggle, active }">
<v-icon class="more" :class="{ active }" name="more_horiz" clickable @click="toggle" />
<div class="date">
{{ formattedTime }}
</div>
</template>
<v-list>
<v-list-item clickable @click="$emit('copy')">
<v-list-item-icon><v-icon name="copy" /></v-list-item-icon>
<v-list-item-content>{{ t('share_copy_link') }}</v-list-item-content>
</v-list-item>
<v-list-item clickable @click="$emit('invite')">
<v-list-item-icon><v-icon name="send" /></v-list-item-icon>
<v-list-item-content>{{ t('share_send_link') }}</v-list-item-content>
</v-list-item>
<v-divider v-if="deleteAllowed && editAllowed" />
<v-list-item v-if="editAllowed" clickable @click="$emit('edit')">
<v-list-item-icon><v-icon name="edit" /></v-list-item-icon>
<v-list-item-content>{{ t('edit') }}</v-list-item-content>
</v-list-item>
<v-list-item v-if="deleteAllowed" clickable @click="$emit('delete')">
<v-list-item-icon><v-icon name="delete" /></v-list-item-icon>
<v-list-item-content>{{ t('delete_label') }}</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
<div class="share-item-info">
<span class="share-uses" :class="{ 'no-left': usesLeft === 0 }">
<template v-if="usesLeft === null">{{ t('unlimited_usage') }}</template>
<template v-else>{{ t('uses_left', usesLeft) }}</template>
</span>
<v-icon v-if="share.password" small name="lock" />
<span style="flex-grow: 1"></span>
<span v-if="status" class="share-status" :class="{ [status]: true }">
{{ t(status) }}
</span>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { format } from 'date-fns';
import { isAllowed } from '@/utils/is-allowed';
export default defineComponent({
props: {
share: {
type: Object,
required: true,
},
},
emits: ['copy', 'edit', 'invite', 'delete'],
setup(props) {
const { t, d } = useI18n();
const editAllowed = computed(() => {
return isAllowed('directus_shares', 'update', props.share);
});
const deleteAllowed = computed(() => {
return isAllowed('directus_shares', 'delete', props.share);
});
const usesLeft = computed(() => {
if (props.share.max_uses === null) return null;
return props.share.max_uses - props.share.times_used;
});
const status = computed(() => {
if (props.share.date_end && new Date(props.share.date_end) < new Date()) {
return 'expired';
}
if (props.share.date_start && new Date(props.share.date_start) > new Date()) {
return 'upcoming';
}
return null;
});
const formattedTime = computed(() => {
return format(new Date(props.share.date_created), String(t('date-fns_date_short')));
});
const confirmDelete = ref<string | null>(null);
return { editAllowed, deleteAllowed, usesLeft, status, t, d, formattedTime, confirmDelete };
},
});
</script>
<style lang="scss" scoped>
.share-item {
margin-bottom: 8px;
padding: 8px;
background-color: var(--background-page);
border-radius: var(--border-radius);
}
.share-item-date {
color: var(--foreground-subdued);
font-size: 12px;
}
.share-item-header {
display: flex;
justify-content: space-between;
margin-bottom: 0;
}
.share-item-info {
display: flex;
align-items: center;
color: var(--foreground-subdued);
}
.share-uses {
margin-right: 5px;
font-size: 12px;
&.no-left {
color: var(--danger);
}
}
.share-status {
font-weight: 600;
font-size: 12px;
text-align: end;
text-transform: uppercase;
&.expired {
color: var(--warning);
}
&.upcoming {
color: var(--green);
}
}
.header-right {
position: relative;
flex-basis: 24px;
color: var(--foreground-subdued);
.more {
cursor: pointer;
opacity: 0;
transition: all var(--slow) var(--transition);
&:hover {
color: var(--foreground-normal);
}
&.active {
opacity: 1;
}
}
.date {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
font-size: 12px;
white-space: nowrap;
text-align: right;
opacity: 1;
transition: opacity var(--slow) var(--transition);
pointer-events: none;
}
.more.active + .date {
opacity: 0;
}
}
.share-item:hover {
&:hover {
.header-right .date {
opacity: 0;
}
.header-right .more {
opacity: 1;
}
}
}
</style>

View File

@@ -0,0 +1,295 @@
<template>
<sidebar-detail :title="t('shares')" icon="share" :badge="count">
<v-notice v-if="error" type="danger">{{ t('unexpected_error') }}</v-notice>
<v-progress-linear v-else-if="loading" indeterminate />
<div v-else-if="!shares || shares.length === 0" class="empty">
<div class="content">{{ t('no_shares') }}</div>
</div>
<template v-for="share in shares" :key="share.id">
<share-item
:share="share"
@copy="copy(share.id)"
@edit="select(share.id)"
@delete="shareToDelete = share"
@invite="shareToSend = share"
/>
</template>
<drawer-item
collection="directus_shares"
:primary-key="shareToEdit"
:active="!!shareToEdit"
@cancel="unselect"
@input="input"
/>
<v-dialog :model-value="!!shareToDelete" @update:model-value="shareToDelete = null" @esc="shareToDelete = null">
<v-card>
<v-card-title>{{ t('delete_comment') }}</v-card-title>
<v-card-text>{{ t('delete_are_you_sure') }}</v-card-text>
<v-card-actions>
<v-button secondary @click="shareToDelete = null">
{{ t('cancel') }}
</v-button>
<v-button kind="danger" :loading="deleting" @click="remove">
{{ t('delete_label') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog :model-value="!!shareToSend" @update:model-value="shareToSend = null" @esc="shareToSend = null">
<v-card>
<v-card-title>{{ t('share_send_link') }}</v-card-title>
<v-card-text>
<div class="grid">
<div class="field">
<v-input disabled :model-value="sendPublicLink" />
</div>
<div class="field">
<div class="type-label">{{ t('emails') }}</div>
<v-textarea v-model="sendEmails" :nullable="false" placeholder="admin@example.com, user@example.com..." />
</div>
</div>
</v-card-text>
<v-card-actions>
<v-button secondary @click="shareToSend = null">
{{ t('cancel') }}
</v-button>
<v-button :loading="sending" @click="send">
{{ t('share_send_link') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-button v-if="allowed" full-width @click="select('+')">
{{ t('new_share') }}
</v-button>
</sidebar-detail>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref, computed } from 'vue';
import DrawerItem from '@/views/private/components/drawer-item';
import { getRootPath } from '@/utils/get-root-path';
import { unexpectedError } from '@/utils/unexpected-error';
import { Share } from '@directus/shared/types';
import api from '@/api';
import ShareItem from './share-item.vue';
export default defineComponent({
components: { ShareItem, DrawerItem },
props: {
collection: {
type: String,
required: true,
},
primaryKey: {
type: [String, Number],
required: true,
},
allowed: {
type: Boolean,
required: true,
},
},
setup(props) {
const { t } = useI18n();
const shares = ref<Share[] | null>(null);
const count = ref(0);
const error = ref(null);
const loading = ref(false);
const deleting = ref(false);
const shareToEdit = ref<string | null>(null);
const shareToSend = ref<Share | null>(null);
const shareToDelete = ref<Share | null>(null);
const sending = ref(false);
const sendEmails = ref('');
const sendPublicLink = computed(() => {
if (!shareToSend.value) return null;
return window.location.origin + getRootPath() + 'admin/shared/' + shareToSend.value.id;
});
refresh();
return {
shareToDelete,
t,
shares,
loading,
error,
refresh,
count,
select,
unselect,
shareToEdit,
input,
copy,
shareToSend,
remove,
deleting,
sendPublicLink,
send,
sending,
sendEmails,
};
async function input(data: any) {
if (!data) return;
data.collection = props.collection;
data.item = props.primaryKey;
try {
if (shareToEdit.value === '+') {
await api.post('/shares', data);
} else {
await api.patch(`/shares/${shareToEdit.value}`, data);
}
await refresh();
shareToEdit.value = null;
} catch (error: any) {
unexpectedError(error);
}
}
async function copy(id: string) {
const url = window.location.origin + getRootPath() + 'admin/shared/' + id;
await navigator?.clipboard?.writeText(url);
}
function select(id: string) {
shareToEdit.value = id;
}
function unselect() {
shareToEdit.value = null;
}
async function refresh() {
error.value = null;
loading.value = true;
try {
const response = await api.get(`/shares`, {
params: {
filter: {
_and: [
{
collection: {
_eq: props.collection,
},
},
{
item: {
_eq: props.primaryKey,
},
},
],
},
sort: 'name',
},
});
count.value = response.data.data.length;
shares.value = response.data.data;
} catch (error: any) {
error.value = error;
} finally {
loading.value = false;
}
}
async function remove() {
if (!shareToDelete.value) return;
deleting.value = true;
try {
await api.delete(`/shares/${shareToDelete.value.id}`);
await refresh();
shareToDelete.value = null;
} catch (err: any) {
unexpectedError(err);
} finally {
deleting.value = false;
}
}
async function send() {
if (!shareToSend.value) return;
sending.value = true;
try {
const emailsParsed = sendEmails.value
.split(/,|\n/)
.filter((e) => e)
.map((email) => email.trim());
await api.post('/shares/invite', {
emails: emailsParsed,
share: shareToSend.value.id,
});
sendEmails.value = '';
shareToSend.value = null;
} catch (err: any) {
unexpectedError(err);
} finally {
sending.value = false;
}
}
},
});
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/form-grid';
.sidebar-detail {
--v-badge-background-color: var(--primary);
}
.v-progress-linear {
margin: 24px 0;
}
.v-divider {
position: sticky;
top: 0;
z-index: 2;
margin-top: 8px;
margin-bottom: 8px;
padding-top: 8px;
padding-bottom: 8px;
background-color: var(--background-normal);
box-shadow: 0 0 4px 2px var(--background-normal);
}
.empty {
margin-top: 16px;
margin-bottom: 16px;
margin-left: 2px;
color: var(--foreground-subdued);
font-style: italic;
}
.grid {
--form-vertical-gap: 20px;
@include form-grid;
}
</style>

View File

@@ -3,7 +3,7 @@
{{ t('no_app_access_copy') }}
<template #append>
<v-button to="/logout">Switch User</v-button>
<v-button to="/logout">{{ t('switch_user') }}</v-button>
</template>
</v-info>

View File

@@ -1,22 +0,0 @@
# Public View
## Props
| Prop | Description | Default |
| ------ | ------------------------------------------------- | ------- |
| `wide` | Renders the container area with a wider max width | `false` |
## Events
n/a
## Slots
| Slot | Description | Data |
| --------- | -------------------------------------------------- | ---- |
| _default_ | | -- |
| `notice` | Notice after all the content. Sticks to the bottom | -- |
## CSS Variables
n/a

View File

@@ -1,9 +1,11 @@
import { App, defineAsyncComponent } from 'vue';
import PublicView from './public/';
import SharedView from './shared/shared-view.vue';
const PrivateView = defineAsyncComponent(() => import('./private'));
export function registerViews(app: App): void {
app.component('PublicView', PublicView);
app.component('PrivateView', PrivateView);
app.component('SharedView', SharedView);
}

View File

@@ -0,0 +1,179 @@
<template>
<div class="shared" :class="{ inline }">
<div class="inline-container">
<header>
<div class="container">
<div class="title-box">
<div
v-if="serverInfo?.project.project_logo"
class="logo"
:style="{ backgroundColor: serverInfo?.project.project_color }"
>
<img :src="logoURL" :alt="serverInfo?.project.project_name || 'Logo'" />
</div>
<div v-else class="logo" :style="{ backgroundColor: serverInfo?.project.project_color }">
<img src="../../assets/logo.svg" alt="Directus" class="directus-logo" />
</div>
<div class="title">
<p class="subtitle">{{ serverInfo?.project.project_name }}</p>
<slot name="title">
<h1 class="type-title">{{ title ?? t('share_access_page') }}</h1>
</slot>
</div>
</div>
</div>
</header>
<div class="container">
<div class="content">
<slot />
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useServerStore } from '@/stores';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
export default defineComponent({
name: 'SharedView',
props: {
title: {
type: String,
default: null,
},
inline: {
type: Boolean,
default: false,
},
},
setup() {
const serverStore = useServerStore();
const { info } = storeToRefs(serverStore);
const { t } = useI18n();
return {
serverInfo: info,
t,
};
},
});
</script>
<style scoped lang="scss">
.shared {
--border-radius: 6px;
--input-height: 60px;
--input-padding: 16px;
width: 100%;
height: 100%;
padding-bottom: 64px;
overflow: auto;
background-color: var(--background-subdued);
}
.inline-container {
display: contents;
}
header {
margin-bottom: 32px;
padding: 10px;
background-color: var(--background-page);
border-bottom: var(--border-width) solid var(--border-subdued);
}
.container {
max-width: 856px;
margin: 0 auto;
}
.title-box {
display: flex;
align-items: center;
width: max-content;
max-width: 100%;
height: 60px;
margin-top: 2px;
.title {
margin-left: 16px;
h1 {
color: var(--foreground-normal);
font-weight: 700;
font-size: 24px;
line-height: 24px;
}
.subtitle {
width: 100%;
color: var(--foreground-subdued);
}
}
}
.logo {
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
background-color: var(--brand);
border-radius: var(--border-radius);
img {
width: 40px;
height: 40px;
object-fit: contain;
object-position: center center;
}
}
.content {
padding: 32px;
background-color: var(--background-page);
border-radius: var(--border-radius);
box-shadow: 0px 4px 12px rgba(38, 50, 56, 0.1);
}
.inline {
display: flex;
align-items: center;
justify-content: center;
.inline-container {
display: block;
width: 100%;
max-width: 856px;
padding: 32px;
background-color: var(--background-page);
border-radius: var(--border-radius);
box-shadow: 0px 4px 12px rgba(38, 50, 56, 0.1);
@media (min-width: 618px) {
width: 618px;
}
}
header {
padding: 0;
border-bottom: 0;
}
.container {
display: contents;
}
.content {
display: contents;
}
}
</style>