Skip to content
Open
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
338 changes: 262 additions & 76 deletions src/lib/components/permissions/row.svelte
Original file line number Diff line number Diff line change
@@ -1,43 +1,121 @@
<script lang="ts">
import { type ComponentProps } 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';
export let role: string;
export let placement: ComponentProps<Popover>['placement'] = 'bottom-start';
interface 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 getData(
permission: string
): Promise<
Partial<Models.User<Record<string, unknown>> & Models.Team<Record<string, unknown>>>
Partial<Models.User<Record<string, unknown>> & Models.Team<Record<string, unknown>>> & {
notFound?: boolean;
roleName?: string;
customName?: string;
}
> {
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;
const parsed = parsePermission(permission);
if (!parsed.isValid || parsed.type === 'other') {
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;
if (parsed.type === 'user') {
try {
const user = await sdk
.forProject(page.params.region, page.params.project)
.users.get({ userId: parsed.id });
return user;
} catch (error) {
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
}
}
if (parsed.type === 'team') {
try {
const team = await sdk
.forProject(page.params.region, page.params.project)
.teams.get({ teamId: parsed.id });
return team;
} catch (error) {
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
}
}
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
}
let isMouseOverTooltip = 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 +126,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 />
<Popover let:show let:hide {placement} portal>
<button
on:mouseenter={() => {
if (!$menuOpen) {
setTimeout(show, 150);
}
}}
on:mouseleave={() => hidePopover(hide)}>
<slot>
{#if isCustomPermission(role)}
<Link.Anchor>
{formatName(role, $isSmallViewport ? 8 : 15)}
</Link.Anchor>
{: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 : 12
)}
{/await}
</Typography.Text>
<Badge
size="xs"
variant="secondary"
content={role.startsWith('user') ? 'User' : 'Team'} />
</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>
{/if}
</slot>
</button>
Comment on lines 151 to 182
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Accessibility: fix nested interactive, add keyboard support, and prevent implicit form submit

  • Avoid nesting <a> inside <button> (invalid, confusing for AT).
  • Add type="button" to avoid unintended form submits.
  • Add focus/blur/keydown handlers so keyboard users can open/close the popover.
-    <Popover let:show let:hide {placement} portal>
-        <button
-            on:mouseenter={() => {
+    <Popover let:show let:hide {placement} portal>
+        <button
+            type="button"
+            aria-haspopup="dialog"
+            aria-label="View permission details"
+            on:mouseenter={() => {
                 if (!$menuOpen) {
                     setTimeout(show, 150);
                 }
             }}
-            on:mouseleave={() => hidePopover(hide)}>
+            on:mouseleave={() => hidePopover(hide)}
+            on:focus={() => { if (!$menuOpen) show(); }}
+            on:blur={() => hidePopover(hide)}
+            on:keydown={(e) => {
+                if (e.key === 'Enter' || e.key === ' ') {
+                    e.preventDefault();
+                    show();
+                }
+                if (e.key === 'Escape') {
+                    hide();
+                }
+            }}>
             <slot>
                 {#if isCustomPermission(role)}
-                    <Link.Anchor>
-                        {formatName(role, $isSmallViewport ? 8 : 15)}
-                    </Link.Anchor>
+                    <Typography.Text>
+                        {formatName(role, $isSmallViewport ? 8 : 15)}
+                    </Typography.Text>
                 {:else}
                     <Layout.Stack direction="row" gap="s" alignItems="center" inline>
                         <Typography.Text>
-                            {#await getData(role)}
+                            {#await dataPromise}
                                 {role}
                             {:then data}
                                 {formatName(
                                     data.name ?? data?.email ?? data?.phone ?? '-',
                                     $isSmallViewport ? 5 : 12
                                 )}
                             {/await}
                         </Typography.Text>
                         <Badge
                             size="xs"
                             variant="secondary"
-                            content={role.startsWith('user') ? 'User' : 'Team'} />
+                            content={parsePermission(role).type === 'user' ? 'User' : 'Team'} />
                     </Layout.Stack>
                 {/if}
             </slot>
         </button>

To support {#await dataPromise} above, add:

let dataPromise: ReturnType<typeof getData>;
$: dataPromise = getData(role);

near the other script declarations.


<div
let:hide
let:showing
slot="tooltip"
role="tooltip"
class="popover"
on:mouseenter={() => (isMouseOverTooltip = true)}
on:mouseleave={() => 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 id = role.split(':')[1].split('/')[0]}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use parsePermission helper instead of manual parsing.

Line 238 manually parses the permission string with role.split(':')[1].split('/')[0], which duplicates logic and is error-prone. Use the parsePermission helper for consistency and safety.

Apply this diff:

+                            {@const parsed = parsePermission(role)}
                             {@const isUser = role.startsWith('user')}
                             {@const isAnonymous =
                                 !data.email && !data.phone && !data.name && isUser}
-                            {@const id = role.split(':')[1].split('/')[0]}
+                            {@const id = parsed.id}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{@const id = role.split(':')[1].split('/')[0]}
{@const parsed = parsePermission(role)}
{@const isUser = role.startsWith('user')}
{@const isAnonymous =
!data.email && !data.phone && !data.name && isUser}
{@const id = parsed.id}
🤖 Prompt for AI Agents
In src/lib/components/permissions/row.svelte around line 238, the code manually
parses the permission id via role.split(':')[1].split('/')[0]; replace this with
the parsePermission helper to avoid duplicated, brittle parsing. Import or
ensure parsePermission is available in this file, call it with the role string
and extract the id from its return (e.g., const { id } = parsePermission(role)),
and remove the manual split expression so the component uses the helper
consistently and safely.


<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 : 24
)}
</Typography.Text>
</Link.Anchor>
</Layout.Stack>
<InteractiveText
isVisible
variant="copy"
text={id}
value={id} />
</Layout.Stack>
</Layout.Stack>

<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>
{#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: 260px;
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