Skip to content
364 changes: 281 additions & 83 deletions src/lib/components/permissions/row.svelte
Original file line number Diff line number Diff line change
@@ -1,43 +1,133 @@
<script lang="ts">
import { type ComponentProps, type Snippet } from 'svelte';
import { sdk } from '$lib/stores/sdk';
import type { Models } from '@appwrite.io/console';
import { AvatarInitials } from '../';
import { isSmallViewport } from '$lib/stores/viewport';
import {
Button,
Badge,
Divider,
Icon,
InteractiveText,
Layout,
Link,
Popover,
Spinner,
Typography
} from '@appwrite.io/pink-svelte';
import Avatar from '../avatar.svelte';
import { IconAnonymous, IconExternalLink, IconMinusSm } from '@appwrite.io/pink-icons-svelte';
import { base } from '$app/paths';
import { IconAnonymous, IconMinusSm } from '@appwrite.io/pink-icons-svelte';
import { page } from '$app/state';
import { menuOpen } from '$lib/components/menu/store';
import { base } from '$app/paths';
import { formatName } from '$lib/helpers/string';
type PermissionData = Partial<Models.User & Models.Team> & {
notFound?: boolean;
roleName?: string;
customName?: string;
};
const permissionDataCache: Map<string, Promise<PermissionData>> = new Map();
interface Props {
role: string;
placement?: ComponentProps<Popover>['placement'];
children?: Snippet;
}
let { role, placement = 'bottom-start', children }: Props = $props();
type ParsedPermission = {
type: 'user' | 'team' | 'other';
id: string;
roleName?: string;
isValid: boolean;
};
function parsePermission(permission: string): ParsedPermission {
try {
const [type, rest] = permission.split(':');
if (!rest) {
return { type: 'other', id: permission, isValid: false };
}
const [id, roleName] = rest.split('/');
if (!id) {
return { type: 'other', id: permission, isValid: false };
}
if (type === 'user' || type === 'team') {
return {
type: type as 'user' | 'team',
id,
roleName,
isValid: true
};
}
return { type: 'other', id: permission, isValid: false };
} catch (error) {
return { type: 'other', id: permission, isValid: false };
}
}
async function fetchPermissionData(parsed: ParsedPermission): Promise<PermissionData> {
if (!parsed.isValid || parsed.type === 'other') {
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
}
if (parsed.type === 'user') {
try {
return await sdk
.forProject(page.params.region, page.params.project)
.users.get({ userId: parsed.id });
} catch (error) {
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
}
}
export let role: string;
async function getData(
permission: string
): Promise<
Partial<Models.User<Record<string, unknown>> & Models.Team<Record<string, unknown>>>
> {
const role = permission.split(':')[0];
const id = permission.split(':')[1].split('/')[0];
if (role === 'user') {
const user = await sdk
.forProject(page.params.region, page.params.project)
.users.get({ userId: id });
return user;
if (parsed.type === 'team') {
try {
return await sdk
.forProject(page.params.region, page.params.project)
.teams.get({ teamId: parsed.id });
} catch (error) {
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
}
}
if (role === 'team') {
const team = await sdk
.forProject(page.params.region, page.params.project)
.teams.get({ teamId: id });
return team;
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
}
async function getData(permission: string): Promise<PermissionData> {
const cached = permissionDataCache.get(permission);
if (cached) return cached;
const parsed = parsePermission(permission);
const fetchPromise = fetchPermissionData(parsed);
permissionDataCache.set(permission, fetchPromise);
return fetchPromise;
}
let isMouseOverTooltip = $state(false);
function hidePopover(hideTooltip: () => void, timeout = true) {
if (!timeout) {
isMouseOverTooltip = false;
return hideTooltip();
}
setTimeout(() => {
if (!isMouseOverTooltip) {
hideTooltip();
}
}, 150);
}
function isCustomPermission(role: string): boolean {
const parsed = parsePermission(role);
return !!parsed.roleName || !parsed.isValid;
}
</script>

Expand All @@ -48,70 +138,178 @@
{:else if role === 'any'}
<div>Any</div>
{:else}
<Popover let:toggle placement="bottom-start">
<Link.Button on:click={toggle}>{role}</Link.Button>
<div let:showing slot="tooltip" style:width="200px">
{#key showing}
{#await getData(role)}
<Layout.Stack alignItems="center">
<Spinner />
</Layout.Stack>
{:then data}
{@const isUser = role.startsWith('user')}
{@const isTeam = role.startsWith('team')}
{@const isAnonymous = !data.email && !data.phone && !data.name && isUser}
<Layout.Stack>
<Layout.Stack direction="row" gap="s" alignItems="center">
{#if isAnonymous}
<Avatar alt="avatar" size="xs">
<Icon icon={IconAnonymous} size="s" />
</Avatar>
{:else if data.name}
<AvatarInitials name={data.name} size="xs" />
{:else}
<Avatar alt="avatar" size="xs">
<Icon icon={IconMinusSm} size="s" />
</Avatar>
{/if}
<Typography.Text truncate color="--fgcolor-neutral-primary">
{data.name ?? data?.email ?? data?.phone ?? '-'}
</Typography.Text>
<Popover let:show let:hide {placement} portal>
<button
onmouseenter={() => {
if (!$menuOpen) {
setTimeout(show, 150);
}
}}
onmouseleave={() => hidePopover(hide)}>
{@render children?.()}
{#if isCustomPermission(role)}
<Typography.Text style="text-decoration: underline;">
{formatName(role, $isSmallViewport ? 8 : 15)}
</Typography.Text>
{:else}
<Layout.Stack direction="row" gap="s" alignItems="center" inline>
<Typography.Text>
{#await getData(role)}
{role}
{:then data}
{formatName(
data.name ?? data?.email ?? data?.phone ?? '-',
$isSmallViewport ? 5 : 7
)}
{/await}
</Typography.Text>
<Badge
size="xs"
variant="secondary"
content={role.startsWith('user') ? 'User' : 'Team'} />
</Layout.Stack>
{/if}
</button>

<div
let:hide
let:showing
slot="tooltip"
role="tooltip"
class="popover"
onmouseenter={() => (isMouseOverTooltip = true)}
onmouseleave={() => hidePopover(hide, false)}>
{#if showing}
<Layout.Stack gap="s" alignContent="flex-start">
{#await getData(role)}
<Layout.Stack alignItems="center">
<Spinner />
</Layout.Stack>
{:then data}
{#if data.notFound}
<Layout.Stack gap="s" alignItems="flex-start">
<Layout.Stack
direction="row"
gap="s"
alignItems="center"
justifyContent="flex-start">
<Avatar alt="avatar" size="m">
<Icon icon={IconMinusSm} size="s" />
</Avatar>

<Layout.Stack alignItems="flex-start" gap="xxs">
<Layout.Stack style="padding-left: 0.25rem;">
<Typography.Text
size="s"
color="--fgcolor-neutral-primary">
{data.customName}
</Typography.Text>
</Layout.Stack>
{#if data.roleName}
<InteractiveText
isVisible
variant="copy"
text={data.roleName}
value={data.roleName} />
{:else}
<InteractiveText
isVisible
variant="copy"
text={role}
value={role} />
{/if}
</Layout.Stack>
</Layout.Stack>
</Layout.Stack>
{:else}
{@const isUser = role.startsWith('user')}
{@const isAnonymous =
!data.email && !data.phone && !data.name && isUser}
{@const parsed = parsePermission(role)}
{@const id = parsed.id}

<Divider />
{#if isUser}
{#if data?.email}
<Typography.Text truncate>Email: {data?.email}</Typography.Text>
{/if}
{#if data?.phone}
<Typography.Text truncate>Phone: {data?.phone}</Typography.Text>
{/if}
<div>
<Button.Anchor
href={`${base}/project-${page.params.region}-${page.params.project}/auth/user-${data?.$id}`}
size="xs"
target="_blank"
variant="secondary">
View user
<Icon slot="end" icon={IconExternalLink} size="s" />
</Button.Anchor>
</div>
{:else if isTeam}
<Typography.Text>Members: {data?.total}</Typography.Text>
<div>
<Button.Anchor
href={`${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${data?.$id}`}
size="s"
target="_blank"
variant="secondary">
View team
<Icon slot="end" icon={IconExternalLink} size="s" />
</Button.Anchor>
</div>
<Layout.Stack gap="s" alignItems="flex-start">
<Layout.Stack
direction="row"
gap="s"
alignItems="center"
justifyContent="flex-start">
{#if isAnonymous}
<Avatar alt="avatar" size="m">
<Icon icon={IconAnonymous} size="s" />
</Avatar>
{:else if data.name}
<AvatarInitials name={data.name} size="m" />
{:else}
<Avatar alt="avatar" size="m">
<Icon icon={IconMinusSm} size="s" />
</Avatar>
{/if}

<Layout.Stack alignItems="flex-start" gap="xxs">
<Layout.Stack style="padding-left: 0.25rem;">
<Link.Anchor
variant="quiet"
href={role.startsWith('user')
? `${base}/project-${page.params.region}-${page.params.project}/auth/user-${id}`
: `${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${id}`}>
<Typography.Text
size="s"
color="--fgcolor-neutral-primary">
{formatName(
data.name ??
data?.email ??
data?.phone ??
'-',
$isSmallViewport ? 12 : 20
)}
</Typography.Text>
</Link.Anchor>
</Layout.Stack>
<InteractiveText
isVisible
variant="copy"
text={id}
value={id} />
</Layout.Stack>
</Layout.Stack>

{#if isUser && (data.email || data.phone)}
<Divider />
<Layout.Stack gap="xs" alignItems="flex-start">
{#if data.email}
<Typography.Text
size="xs"
color="--fgcolor-neutral-secondary">
Email: {data.email}
</Typography.Text>
{/if}
{#if data.phone}
<Typography.Text
size="xs"
color="--fgcolor-neutral-secondary">
Phone: {data.phone}
</Typography.Text>
{/if}
</Layout.Stack>
{/if}
</Layout.Stack>
{/if}
</Layout.Stack>
{/await}
{/key}
{/await}
</Layout.Stack>
{/if}
</div>
</Popover>
{/if}

<style lang="scss">
.popover {
display: flex;
width: 280px;
min-width: 260px;
padding: var(--space-5, 10px) var(--space-6, 12px);
align-items: flex-start;
gap: var(--gap-XXS, 4px);
margin: -1rem;
}
</style>
Loading