mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}...
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>[] = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '/:_(.+)+',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
36
app/src/routes/shared/components/share-item.vue
Normal file
36
app/src/routes/shared/components/share-item.vue
Normal 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>
|
||||
4
app/src/routes/shared/index.ts
Normal file
4
app/src/routes/shared/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import SharedRoute from './shared.vue';
|
||||
|
||||
export { SharedRoute };
|
||||
export default SharedRoute;
|
||||
235
app/src/routes/shared/shared.vue
Normal file
235
app/src/routes/shared/shared.vue
Normal 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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './error';
|
||||
export * from './insights';
|
||||
export * from './notifications';
|
||||
export * from './login';
|
||||
export * from './shares';
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import SharesSidebarDetail from './shares-sidebar-detail.vue';
|
||||
|
||||
export { SharesSidebarDetail };
|
||||
export default SharesSidebarDetail;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
179
app/src/views/shared/shared-view.vue
Normal file
179
app/src/views/shared/shared-view.vue
Normal 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>
|
||||
Reference in New Issue
Block a user