Add multi-tenant teams to your authenticated app. Creates PocketBase collections for teams, memberships, and invites, plus a /teams management UI for creating teams, inviting members by email or shareable link, and changing roles across owner, admin, and member. A sidebar team switcher lets users move between workspaces, with invalidate('app:team') refreshing data on switch. Requires auth to be enabled first.
<script lang="ts">
import type { Snippet } from "svelte";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
import PlusIcon from "@lucide/svelte/icons/plus";
import UsersIcon from "@lucide/svelte/icons/users";
import { page } from "$app/state";
import { goto, invalidate } from "$app/navigation";
let {
teams,
children,
}: { teams: { id: string; name: string }[] } & { children: Snippet } =
$props();
const switchTeam = async (teamId: string) => {
await goto(
`/teams/switch/${teamId}?redirect=${encodeURIComponent(page.url.pathname)}`,
);
invalidate("app:team");
};
const sidebar = useSidebar();
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
{@render children?.()}
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
align="start"
side={sidebar.isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenu.Label class="text-muted-foreground text-xs"
>Teams</DropdownMenu.Label
>
{#each teams as team (team.name)}
<DropdownMenu.Item
onSelect={() => switchTeam(team.id)}
class="gap-2 p-2"
>
<div
class="flex size-6 items-center justify-center rounded-md border"
>
<UsersIcon class="size-3.5 shrink-0" />
</div>
{team.name}
</DropdownMenu.Item>
{/each}
<DropdownMenu.Separator />
<DropdownMenu.Item class="gap-2 p-2">
<div
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
>
<PlusIcon class="size-4" />
</div>
<a href="/teams/new" class="text-muted-foreground font-medium"
>Add team</a
>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
import { z } from "zod";
export const teamSchema = z.object({
name: z.string(),
});
import { z } from "zod";
export const teamInviteSchema = z.object({
email: z.email(),
});
import { z } from "zod";
export const teamRoleSchema = z.object({
role: z.enum(["owner", "admin", "member"]),
});
<script lang="ts">
import RootLayout from "../../../(public)/root-layout.svelte";
let { data, children } = $props();
</script>
<RootLayout {data}>
{@render children?.()}
</RootLayout>
import { error } from "@sveltejs/kit";
import { redirect } from "sveltekit-flash-message/server";
export const load = async ({ params, locals }) => {
let invite;
try {
invite = await locals.pb.collection("team_invites").getOne(params.id, {
expand: "team",
});
} catch {
return error(404, { message: "Invite not found" });
}
return { invite };
};
export const actions = {
default: async ({ locals, params, cookies }) => {
const user = locals.pb.authStore.record!;
let invite;
try {
invite = await locals.pb.collection("team_invites").getOne(params.id);
} catch {
return error(404, { message: "Invite not found" });
}
await locals.admin.collection("team_memberships").create({
user: user.id,
team: invite.team,
role: "member",
});
await locals.admin.collection("team_invites").delete(invite.id);
redirect(
303,
`/dashboard`,
{ type: "toast", message: "Joined team" },
cookies,
);
},
};
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
let { data } = $props();
</script>
<div class="flex flex-col flex-1 items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-6">
<a href="/" class="flex items-center gap-2 self-center font-medium">
<div
class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
>
<img src="/favicon.svg" alt="logo" class="size-4" />
</div>
{data.meta.appName}
</a>
<div class="flex flex-col gap-6">
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl"
>{data.invite.expand?.team.name} invited you to join their team</Card.Title
>
<Card.Description>Accept the invite to join the team</Card.Description
>
</Card.Header>
<Card.Content>
<form method="POST">
<div class="grid gap-6">
<div class="grid gap-2">
<Button type="submit" class="w-full">Accept Invite</Button>
</div>
</div>
</form>
</Card.Content>
</Card.Root>
</div>
</div>
</div>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Invitation",
});
return { ...data, pageMetaTags };
};
<script lang="ts">
import RootLayout from "../../../(public)/root-layout.svelte";
let { data, children } = $props();
</script>
<RootLayout {data}>
{@render children?.()}
</RootLayout>
import { error } from "@sveltejs/kit";
import { redirect } from "sveltekit-flash-message/server";
export const load = async ({ params, locals }) => {
let invite;
try {
invite = await locals.pb.collection("team_invite_links").getOne(params.id, {
expand: "team",
});
} catch {
return error(404, { message: "Invite link not found" });
}
return { invite };
};
export const actions = {
default: async ({ locals, params, cookies }) => {
const user = locals.pb.authStore.record!;
let invite;
try {
invite = await locals.pb
.collection("team_invite_links")
.getOne(params.id, {
expand: "team",
});
} catch {
return error(404, { message: "Invite link not found" });
}
await locals.admin.collection("team_memberships").create({
user: user.id,
team: invite.team,
role: "member",
});
redirect(
303,
`/dashboard`,
{ type: "toast", message: "Joined team" },
cookies,
);
},
};
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
let { data } = $props();
</script>
<div class="flex flex-col flex-1 items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-6">
<a href="/" class="flex items-center gap-2 self-center font-medium">
<div
class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
>
<img src="/favicon.svg" alt="logo" class="size-4" />
</div>
{data.meta.appName}
</a>
<div class="flex flex-col gap-6">
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl"
>{data.invite.expand?.team.name} invited you to join their team</Card.Title
>
<Card.Description>Accept the invite to join the team</Card.Description
>
</Card.Header>
<Card.Content>
<form method="POST">
<div class="grid gap-6">
<div class="grid gap-2">
<Button type="submit" class="w-full">Accept Invite</Button>
</div>
</div>
</form>
</Card.Content>
</Card.Root>
</div>
</div>
</div>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Join team",
});
return { ...data, pageMetaTags };
};
import { redirect } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { teamSchema } from "$lib/schemas/team";
export const load = async ({ params, locals, parent }) => {
const { user, breadcrumbs } = await parent();
const { id } = params;
let team;
try {
team = await locals.pb.collection("teams").getOne(id, {
expand: "team_invite_links_via_team",
});
} catch {
redirect(303, "/teams");
}
let userMembership;
try {
userMembership = await locals.pb.collection("team_users").getFirstListItem(
locals.pb.filter(
'team = {:team} && id = {:user} && (role = "admin" || role = "owner")',
{
team: id,
user: user.id,
},
),
);
} catch (error) {
redirect(303, "/teams");
}
const role = userMembership.role;
// Get all team members with their roles
const teamMembers = await locals.pb.collection("team_users").getFullList({
filter: locals.pb.filter("team = {:team}", { team: id }),
});
// Get pending invites for this team
const pendingInvites = await locals.pb
.collection("team_invites")
.getFullList({
filter: locals.pb.filter("team = {:team}", { team: id }),
});
const form = await superValidate(team, zod4(teamSchema));
return {
team,
role,
teamMembers,
pendingInvites,
inviteLink: team.expand?.team_invite_links_via_team,
form,
breadcrumbs: [
...breadcrumbs,
{ title: "Teams", url: "/teams" },
{ title: team.name, url: `/teams/${id}` },
],
};
};
<script lang="ts">
import { untrack } from "svelte";
import * as Card from "$lib/components/ui/card";
import { Button, buttonVariants } from "$lib/components/ui/button";
import CrownIcon from "@lucide/svelte/icons/crown";
import ShieldIcon from "@lucide/svelte/icons/shield";
import UserCheckIcon from "@lucide/svelte/icons/user-check";
import UserPlusIcon from "@lucide/svelte/icons/user-plus";
import CopyIcon from "@lucide/svelte/icons/copy";
import MailIcon from "@lucide/svelte/icons/mail";
import ClockIcon from "@lucide/svelte/icons/clock";
import * as Avatar from "$lib/components/ui/avatar";
import { Badge } from "$lib/components/ui/badge";
import * as Table from "$lib/components/ui/table";
import { getAuthorization } from "./authorization";
import { toast } from "svelte-sonner";
import * as AlertDialog from "$lib/components/ui/alert-dialog";
import * as Form from "$lib/components/ui/form";
import TrashIcon from "@lucide/svelte/icons/trash-2";
import { Input } from "$lib/components/ui/input";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { teamSchema } from "$lib/schemas/team";
let { data, children } = $props();
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(teamSchema),
warnings: {
duplicateId: false,
},
},
);
const { form: formData } = form;
function getRoleIcon(role: "owner" | "admin" | "member") {
switch (role) {
case "owner":
return CrownIcon;
case "admin":
return ShieldIcon;
case "member":
return UserCheckIcon;
}
}
function handleCopyLink(inviteStr: string) {
const link = `${data.meta.appURL}/${inviteStr}`;
navigator.clipboard.writeText(link);
toast.success("Link copied to clipboard");
}
</script>
<section data-slot="content">
{#if data.role === "owner"}
<div class="divide-y divide-border">
<div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
<div class="px-4 sm:px-0">
<h2 class="text-base/7 font-semibold text-foreground">
Team Settings
</h2>
<p class="mt-1 text-sm/6 text-muted-foreground">
Update your team name and manage basic team settings.
</p>
</div>
<Card.Root class="md:col-span-2">
<form method="POST" action="?/updateTeam" class="space-y-4">
<Card.Header>
<Card.Title class="text-lg font-semibold">Team Name</Card.Title>
<Card.Description
>Update your team's display name</Card.Description
>
</Card.Header>
<Card.Content>
<div class="space-y-2">
<Form.Field {form} name="name">
<Form.Control>
{#snippet children({ props })}
<Input
{...props}
type="text"
bind:value={$formData.name}
required
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
</div>
</Card.Content>
<Card.Footer class="flex justify-end pt-4 border-t">
<Button type="submit" class="flex items-center" size="sm"
>Save changes</Button
>
</Card.Footer>
</form>
</Card.Root>
</div>
</div>
{/if}
<div class="divide-y divide-border">
<div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
<div class="px-4 sm:px-0">
<h2 class="text-base/7 font-semibold text-foreground">Team Members</h2>
<p class="mt-1 text-sm/6 text-muted-foreground">
Manage team members and their roles. Control access permissions and
organize your team effectively.
</p>
<div class="mt-4 space-y-2">
<div class="flex items-center text-sm text-muted-foreground">
<div class="w-2 h-2 bg-muted-foreground rounded-full mr-2"></div>
<span>Owner - Full team control</span>
</div>
<div class="flex items-center text-sm text-muted-foreground">
<div class="w-2 h-2 bg-muted-foreground rounded-full mr-2"></div>
<span>Admin - Manage members</span>
</div>
<div class="flex items-center text-sm text-muted-foreground">
<div class="w-2 h-2 bg-muted-foreground rounded-full mr-2"></div>
<span>Member - Team access</span>
</div>
</div>
</div>
<Card.Root class="md:col-span-2">
<Card.Header>
<Card.Title class="text-lg font-semibold"
>{data.team.name} Members</Card.Title
>
<Card.Description
>Manage team members and their roles</Card.Description
>
</Card.Header>
<Card.Content>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Member</Table.Head>
<Table.Head>Role</Table.Head>
<Table.Head></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<!-- Team Members -->
{#each data.teamMembers as member}
{@const { canRemove, canChangeRole } = getAuthorization({
user: data.user,
role: data.role,
member,
})}
<Table.Row>
<Table.Cell>
<div class="flex items-center space-x-3">
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Fallback
class="rounded-lg bg-primary text-primary-foreground font-medium"
>
{member.name?.charAt(0).toUpperCase() ||
member.email.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col">
<div class="font-medium text-foreground">
{member.name || member.email}
</div>
<div class="text-sm text-muted-foreground">
{member.email}
</div>
</div>
</div>
</Table.Cell>
<Table.Cell>
<Badge
variant="outline"
class="flex items-center space-x-1 w-fit"
>
{@const Icon = getRoleIcon(member.role)}
<Icon class="w-3 h-3" />
<span class="capitalize">{member.role}</span>
</Badge>
</Table.Cell>
<Table.Cell>
<div class="flex space-x-2 justify-end">
{#if canChangeRole}
<Button
href="/teams/{data.team
.id}/member/{member.membership_id}"
variant="outline"
size="sm"
data-sveltekit-noscroll
>
Change Role
</Button>
{/if}
{#if canRemove}
<form method="POST" action="?/removeMember">
<input
type="hidden"
name="member_id"
value={member.membership_id}
/>
<Button type="submit" variant="outline" size="sm"
>Remove</Button
>
</form>
{/if}
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</Card.Content>
<Card.Footer class="flex justify-between items-center pt-4 border-t">
<div class="text-sm text-muted-foreground">
{data.teamMembers?.length || 0} member{(data.teamMembers?.length ||
0) !== 1
? "s"
: ""}
total
</div>
</Card.Footer>
</Card.Root>
</div>
</div>
<div class="divide-y divide-border">
<div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
<div class="px-4 sm:px-0">
<h2 class="text-base/7 font-semibold text-foreground">Team Invites</h2>
<p class="mt-1 text-sm/6 text-muted-foreground">
Invitations that have been sent but not yet accepted. You can resend
or cancel pending invites.
</p>
</div>
<Card.Root class="md:col-span-2">
<Card.Header>
<Card.Title class="text-lg font-semibold">Invitations</Card.Title>
<Card.Description>Manage pending team invitations</Card.Description>
</Card.Header>
<Card.Content>
{#if data.pendingInvites && data.pendingInvites.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Email</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.pendingInvites as invite}
<Table.Row>
<Table.Cell>
<div class="flex items-center space-x-3">
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Fallback
class="rounded-lg bg-muted text-muted-foreground font-medium"
>
<MailIcon class="w-4 h-4" />
</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col">
<div class="font-medium text-foreground">
{invite.email}
</div>
<div class="text-sm text-muted-foreground">
Pending invitation
</div>
</div>
</div>
</Table.Cell>
<Table.Cell>
<Badge
variant="outline"
class="flex items-center space-x-1 w-fit"
>
<ClockIcon class="w-3 h-3" />
<span>Pending</span>
</Badge>
</Table.Cell>
<Table.Cell>
<div class="flex space-x-2 justify-end">
<Button
variant="outline"
size="sm"
onclick={() => handleCopyLink(`invite/${invite.id}`)}
>Copy link</Button
>
<form method="POST" action="?/resendInvite">
<input
type="hidden"
name="invite_id"
value={invite.id}
/>
<Button type="submit" variant="outline" size="sm"
>Resend</Button
>
</form>
<form method="POST" action="?/cancelInvite">
<input
type="hidden"
name="invite_id"
value={invite.id}
/>
<Button type="submit" variant="outline" size="sm"
>Cancel</Button
>
</form>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{:else}
<div class="text-center py-8">
<div
class="mx-auto w-12 h-12 bg-muted rounded-full flex items-center justify-center mb-4"
>
<MailIcon class="w-6 h-6 text-muted-foreground" />
</div>
<h3 class="text-lg font-medium text-foreground mb-2">
No pending invites
</h3>
<p class="text-muted-foreground mb-4">
All invitations have been accepted or there are no pending
invites
</p>
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-between items-center pt-4 border-t">
<div class="text-sm text-muted-foreground">
{#if data.inviteLink}
<div class="text-sm text-muted-foreground">
{data.meta.appURL}/join/{data.inviteLink.id}
</div>
{/if}
</div>
<div class="flex items-center space-x-2">
{#if data.inviteLink}
<Button
variant="outline"
size="sm"
onclick={() => handleCopyLink(`join/${data.inviteLink.id}`)}
>
<CopyIcon class="w-4 h-4" />
Copy invite link
</Button>
{/if}
<Button
size="sm"
href="/teams/{data.team.id}/invite"
class="ml-auto"
data-sveltekit-noscroll
>
<UserPlusIcon class="w-4 h-4" />
Invite member
</Button>
</div>
</Card.Footer>
</Card.Root>
</div>
</div>
{#if data.role === "owner"}
<div class="divide-y divide-border">
<div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
<div class="px-4 sm:px-0">
<h2 class="text-base/7 font-semibold text-foreground">Danger Zone</h2>
<p class="mt-1 text-sm/6 text-muted-foreground">
Permanently delete this team and all associated data. This action
cannot be undone.
</p>
</div>
<Card.Root class="md:col-span-2">
<Card.Header>
<Card.Title class="text-lg font-semibold">Delete Team</Card.Title>
<Card.Description>
Remove this team and all associated data permanently
</Card.Description>
</Card.Header>
<Card.Content>
<div
class="flex items-center space-x-3 p-4 bg-destructive/10 rounded-lg border border-destructive/20"
>
<div class="flex-shrink-0">
<TrashIcon class="w-5 h-5 text-destructive" />
</div>
<div class="flex-1">
<p class="text-sm font-medium text-destructive">
Delete this team
</p>
<p class="text-sm text-muted-foreground">
This will permanently delete the team, remove all members, and
delete all associated data.
</p>
</div>
</div>
</Card.Content>
<Card.Footer class="flex justify-end pt-4 border-t">
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({ variant: "destructive", size: "sm" })}
>
<TrashIcon class="w-4 h-4" />
Delete team
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Team</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete "{data.team.name}"? This
action cannot be undone. This will permanently delete the
team, remove all members, and delete all associated data.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<form method="POST" action="?/deleteTeam" class="inline">
<AlertDialog.Action>
<TrashIcon class="w-4 h-4" />
Delete team
</AlertDialog.Action>
</form>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</Card.Footer>
</Card.Root>
</div>
</div>
{/if}
</section>
{@render children?.()}
import { error, fail } from "@sveltejs/kit";
import { redirect } from "sveltekit-flash-message/server";
import { zod4 } from "sveltekit-superforms/adapters";
import { superValidate } from "sveltekit-superforms";
import { teamSchema } from "$lib/schemas/team";
import { setPocketbaseErrors } from "@velastack/pocketbase";
export const actions = {
updateTeam: async ({ locals, params, request, cookies }) => {
const form = await superValidate(request, zod4(teamSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
await locals.pb
.collection("teams")
.update(params.id, { name: form.data.name });
} catch (error) {
setPocketbaseErrors(form, error);
return fail(400, { form });
}
redirect(
303,
`/teams/${params.id}`,
{ type: "toast", message: "Team updated" },
cookies,
);
},
removeMember: async ({ locals, params, request, cookies }) => {
const formData = await request.formData();
const memberId = formData.get("member_id") as string;
if (!memberId) {
return error(400, { message: "Member ID is required" });
}
try {
await locals.pb.collection("team_memberships").delete(memberId);
} catch {
return error(400, { message: "Failed to remove member" });
}
redirect(
303,
`/teams/${params.id}`,
{ type: "toast", message: "Member removed" },
cookies,
);
},
resendInvite: async ({ locals }) => {
const user = locals.pb.authStore.record!;
},
cancelInvite: async ({ locals, params, request, cookies }) => {
const formData = await request.formData();
const inviteId = formData.get("invite_id") as string;
if (!inviteId) {
return error(400, { message: "Invite ID is required" });
}
try {
await locals.pb.collection("team_invites").delete(inviteId);
} catch {
return error(400, { message: "Failed to cancel invite" });
}
redirect(
303,
`/teams/${params.id}`,
{ type: "toast", message: "Invite cancelled" },
cookies,
);
},
deleteTeam: async ({ locals, params, cookies }) => {
try {
await locals.pb.collection("teams").delete(params.id);
} catch {
return error(400, { message: "Failed to delete team" });
}
const team = cookies.get("team");
if (team === params.id) {
cookies.delete("team", { path: "/" });
}
redirect(
303,
`/teams`,
{ type: "toast", message: "Team deleted successfully" },
cookies,
);
},
};
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Team",
});
return { ...data, pageMetaTags };
};
type Role = "owner" | "admin" | "member";
interface User {
id: string;
}
interface Member {
id: string;
role: Role;
}
export const getAuthorization = ({
user,
role,
member,
}: {
user: User;
role: Role;
member?: Member;
}) => {
const isSelf = member ? user.id === member.id : false;
const isOwner = role === "owner";
const isAdmin = role === "admin";
if (isSelf) {
return {
canRemove: false,
canChangeRole: false,
};
} else if (isOwner) {
return {
canRemove: true,
canChangeRole: true,
};
} else if (isAdmin) {
if (member && member.role === "member") {
return {
canRemove: true,
canChangeRole: false,
};
}
return {
canRemove: false,
canChangeRole: false,
};
}
return {
canRemove: false,
canChangeRole: false,
};
};
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { teamInviteSchema } from "$lib/schemas/teamInvite";
import { redirect } from "sveltekit-flash-message/server";
import { setPocketbaseErrors } from "@velastack/pocketbase";
export const load = async () => {
const form = await superValidate(zod4(teamInviteSchema));
return { form };
};
export const actions = {
default: async ({ locals, params, request, cookies }) => {
const form = await superValidate(request, zod4(teamInviteSchema));
if (!form.valid) {
return fail(400, { form });
}
const team = await locals.pb.collection("teams").getOne(params.id);
try {
await locals.pb.collection("team_invites").create({
email: form.data.email,
team: params.id,
name: team.name,
});
} catch (error) {
setPocketbaseErrors(form, error);
return fail(400, { form });
}
redirect(
`/teams/${params.id}`,
{ type: "toast", message: "Invite sent" },
cookies,
);
},
};
<script lang="ts">
import { untrack } from "svelte";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { goto } from "$app/navigation";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { teamInviteSchema } from "$lib/schemas/teamInvite";
import * as Form from "$lib/components/ui/form";
let { data } = $props();
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(teamInviteSchema),
},
);
const { form: formData } = form;
const handleOpenChange = (open: boolean) => {
if (!open) {
goto(`/teams/${data.team.id}`, { noScroll: true });
}
};
</script>
<Dialog.Root open onOpenChange={handleOpenChange}>
<Dialog.Content>
<form method="POST">
<Dialog.Header class="mb-4">
<Dialog.Title>Invite to Team</Dialog.Title>
</Dialog.Header>
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input {...props} type="email" bind:value={$formData.email} />
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Dialog.Footer class="mt-4">
<Form.Button>Send Invite</Form.Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Invite to team",
});
return { ...data, pageMetaTags };
};
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { Match } from "@velastack/pocketbase";
import * as devalue from "devalue";
import type { RouteId } from "./$types";
let team: { id: string; name: string };
beforeEach(async (context) => {
await context.agent.authenticateUser();
team = await context.admin.collection("teams").create({
name: "Test Team",
owner: context.user.id,
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: context.user.id,
role: "owner",
});
});
afterEach(async (context) => {
try {
await context.admin.collection("teams").delete(team.id);
} catch {}
});
describe("GET /teams/[id]/invite", () => {
it("authenticated user should be able to access invite page", async (context) => {
const response = await context.agent.get(
`/teams/${team.id}/invite` satisfies Match<RouteId>,
);
expect(response.status).toBe(200);
});
});
describe("POST /teams/[id]/invite", () => {
it("owner should be able to create invite with valid email", async (context) => {
const inviteEmail = `test-${Math.random().toString(36).slice(2)}@example.com`;
await context.agent
.post(`/teams/${team.id}/invite` satisfies Match<RouteId>)
.type("form")
.send({
email: inviteEmail,
});
const invites = await context.admin
.collection("team_invites")
.getList(1, 1, {
filter: `team = "${team.id}" && email = "${inviteEmail}"`,
});
expect(invites.items).toHaveLength(1);
expect(invites.items[0].email).toBe(inviteEmail);
expect(invites.items[0].team).toBe(team.id);
});
it("admin should be able to create invite with valid email", async (context) => {
const email = `test-${Math.random().toString(36).slice(2)}@example.com`;
const password = "password";
const adminUser = await context.admin.collection("users").create({
email,
password,
passwordConfirm: password,
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: adminUser.id,
role: "admin",
});
await context.agent.post("/login").type("form").send({
type: "password",
email,
password,
});
const inviteEmail = `test-${Math.random().toString(36).slice(2)}@example.com`;
await context.agent
.post(`/teams/${team.id}/invite` satisfies Match<RouteId>)
.type("form")
.send({
email: inviteEmail,
});
const invites = await context.admin
.collection("team_invites")
.getList(1, 1, {
filter: `team = "${team.id}" && email = "${inviteEmail}"`,
});
expect(invites.items).toHaveLength(1);
expect(invites.items[0].email).toBe(inviteEmail);
expect(invites.items[0].team).toBe(team.id);
context.admin.collection("users").delete(adminUser.id);
});
it("member should not be able to create invite", async (context) => {
const email = `test-${Math.random().toString(36).slice(2)}@example.com`;
const password = "password";
const memberUser = await context.admin.collection("users").create({
email,
password,
passwordConfirm: password,
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: memberUser.id,
role: "member",
});
await context.agent.post("/login").type("form").send({
type: "password",
email,
password,
});
const inviteEmail = `test-${Math.random().toString(36).slice(2)}@example.com`;
await context.agent
.post(`/teams/${team.id}/invite` satisfies Match<RouteId>)
.type("form")
.send({
email: inviteEmail,
});
const invites = await context.admin
.collection("team_invites")
.getList(1, 1, {
filter: `team = "${team.id}" && email = "${inviteEmail}"`,
});
expect(invites.items).toHaveLength(0);
context.admin.collection("users").delete(memberUser.id);
});
it("should return 400 for invalid email", async (context) => {
const response = await context.agent
.post(`/teams/${team.id}/invite` satisfies Match<RouteId>)
.type("form")
.send({
email: "invalid-email",
});
const data = devalue.parse(response.body.data);
expect(data.form.valid).toBe(false);
});
it("should return 400 for missing email", async (context) => {
const response = await context.agent
.post(`/teams/${team.id}/invite` satisfies Match<RouteId>)
.type("form")
.send({});
const data = devalue.parse(response.body.data);
expect(data.form.valid).toBe(false);
});
it("should return 400 for empty email", async (context) => {
const response = await context.agent
.post(`/teams/${team.id}/invite` satisfies Match<RouteId>)
.type("form")
.send({
email: "",
});
const data = devalue.parse(response.body.data);
expect(data.form.valid).toBe(false);
});
});
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { teamRoleSchema } from "$lib/schemas/teamRole";
import { redirect } from "sveltekit-flash-message/server";
import { setPocketbaseErrors } from "@velastack/pocketbase";
export const load = async ({ locals, params }) => {
const member = await locals.pb
.collection("team_memberships")
.getOne(params.member_id);
const form = await superValidate(member, zod4(teamRoleSchema));
return { form };
};
export const actions = {
default: async ({ locals, params, request, cookies }) => {
const form = await superValidate(request, zod4(teamRoleSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
await locals.pb.collection("team_memberships").update(params.member_id, {
role: form.data.role,
});
} catch (error) {
setPocketbaseErrors(form, error);
return fail(400, { form });
}
redirect(
`/teams/${params.id}`,
{ type: "toast", message: "Role updated successfully" },
cookies,
);
},
};
<script lang="ts">
import { untrack } from "svelte";
import * as Dialog from "$lib/components/ui/dialog";
import { goto } from "$app/navigation";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { teamRoleSchema } from "$lib/schemas/teamRole";
import * as Form from "$lib/components/ui/form";
import * as Select from "$lib/components/ui/select";
let { data } = $props();
const roleLabels = {
owner: {
label: "Owner",
value: "owner",
},
admin: {
label: "Admin",
value: "admin",
},
member: {
label: "Member",
value: "member",
},
} as const;
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(teamRoleSchema),
},
);
const { form: formData } = form;
const handleOpenChange = (open: boolean) => {
if (!open) {
goto(`/teams/${data.team.id}`, { noScroll: true });
}
};
</script>
<Dialog.Root open onOpenChange={handleOpenChange}>
<Dialog.Content>
<form method="POST">
<Dialog.Header class="mb-4">
<Dialog.Title>Change Role</Dialog.Title>
</Dialog.Header>
<Form.Field {form} name="role">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Role</Form.Label>
<Select.Root type="single" {...props} bind:value={$formData.role}>
<Select.Trigger class="w-full">
{roleLabels[$formData.role].label}
</Select.Trigger>
<Select.Content>
{#each Object.values(roleLabels) as role}
<Select.Item value={role.value}>{role.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Dialog.Footer class="mt-4">
<Form.Button>Update Role</Form.Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Team member",
});
return { ...data, pageMetaTags };
};
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { Match } from "@velastack/pocketbase";
import * as devalue from "devalue";
import type { RouteId } from "./$types";
let team: { id: string; name: string };
let member: { id: string; user: string; role: string };
beforeEach(async (context) => {
await context.agent.authenticateUser();
team = await context.admin.collection("teams").create({
name: "Test Team",
owner: context.user.id,
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: context.user.id,
role: "owner",
});
// Create a test member
const memberUser = await context.admin.collection("users").create({
email: `test-${Math.random().toString(36).slice(2)}@example.com`,
password: "password",
passwordConfirm: "password",
});
member = await context.admin.collection("team_memberships").create({
team: team.id,
user: memberUser.id,
role: "member",
});
});
afterEach(async (context) => {
try {
await context.admin.collection("teams").delete(team.id);
} catch {}
});
describe("GET /teams/[id]/member/[member_id]", () => {
it("authenticated user should be able to access member page", async (context) => {
const response = await context.agent.get(
`/teams/${team.id}/member/${member.id}` satisfies Match<RouteId>,
);
expect(response.status).toBe(200);
});
});
describe("POST /teams/[id]/member/[member_id]", () => {
it("owner should be able to update member role", async (context) => {
await context.agent
.post(`/teams/${team.id}/member/${member.id}` satisfies Match<RouteId>)
.type("form")
.send({
role: "admin",
});
// Verify role was updated
const updatedMember = await context.admin
.collection("team_memberships")
.getOne(member.id);
expect(updatedMember.role).toBe("admin");
});
it("admin should be able to update member role", async (context) => {
const email = `test-${Math.random().toString(36).slice(2)}@example.com`;
const password = "password";
const adminUser = await context.admin.collection("users").create({
email,
password,
passwordConfirm: password,
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: adminUser.id,
role: "admin",
});
await context.agent.post("/login").type("form").send({
type: "password",
email,
password,
});
await context.agent
.post(`/teams/${team.id}/member/${member.id}` satisfies Match<RouteId>)
.type("form")
.send({
role: "member",
});
// Verify role was updated
const updatedMember = await context.admin
.collection("team_memberships")
.getOne(member.id);
expect(updatedMember.role).toBe("member");
context.admin.collection("users").delete(adminUser.id);
});
it("member should not be able to update member role", async (context) => {
const email = `test-${Math.random().toString(36).slice(2)}@example.com`;
const password = "password";
const memberUser = await context.admin.collection("users").create({
email,
password,
passwordConfirm: password,
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: memberUser.id,
role: "member",
});
await context.agent.post("/login").type("form").send({
type: "password",
email,
password,
});
await context.agent
.post(`/teams/${team.id}/member/${member.id}` satisfies Match<RouteId>)
.type("form")
.send({
role: "admin",
});
// Verify role was not updated
const unchangedMember = await context.admin
.collection("team_memberships")
.getOne(member.id);
expect(unchangedMember.role).toBe("member");
context.admin.collection("users").delete(memberUser.id);
});
it("should return 400 for invalid role", async (context) => {
const response = await context.agent
.post(`/teams/${team.id}/member/${member.id}` satisfies Match<RouteId>)
.type("form")
.send({
role: "invalid-role",
});
const data = devalue.parse(response.body.data);
expect(data.form.valid).toBe(false);
});
it("should return 400 for empty role", async (context) => {
const response = await context.agent
.post(`/teams/${team.id}/member/${member.id}` satisfies Match<RouteId>)
.type("form")
.send({
role: "",
});
const data = devalue.parse(response.body.data);
expect(data.form.valid).toBe(false);
});
it("should return 400 for invalid member ID", async (context) => {
const response = await context.agent
.post(`/teams/${team.id}/member/invalid-id` satisfies Match<RouteId>)
.type("form")
.send({
role: "admin",
});
const data = devalue.parse(response.body.data);
expect(data.form.valid).toBe(false);
});
});
import {
describe,
it,
expect,
beforeEach,
afterEach,
type TestContext,
} from "vitest";
import type { Match } from "@velastack/pocketbase";
import type { RouteId } from "./$types";
let team: { id: string; name: string };
beforeEach(async (context) => {
await context.agent.authenticateUser();
team = await context.admin.collection("teams").create({
name: "Test Team",
owner: context.user.id,
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: context.user.id,
role: "owner",
});
});
afterEach(async (context) => {
try {
await context.admin.collection("teams").delete(team.id);
} catch {}
});
describe("GET /teams/[id]", () => {
it("authenticated user should be able to access a team page", async (context) => {
const response = await context.agent.get(
`/teams/${team.id}` satisfies Match<RouteId>,
);
expect(response.status).toBe(200);
});
});
describe("POST /teams/[id]?/updateTeam", () => {
it("owner should be able to update team", async (context) => {
await context.agent
.post(`/teams/${team.id}?/updateTeam` satisfies Match<RouteId>)
.type("form")
.send({
name: "Updated Team Name",
});
const updatedTeam = await context.admin.collection("teams").getOne(team.id);
expect(updatedTeam.name).toBe("Updated Team Name");
});
it("admin should not be able to update team", async (context) => {
const email = `test-${Math.random().toString(36).slice(2)}@example.com`;
const password = "password";
const adminUser = await context.admin.collection("users").create({
email,
password,
passwordConfirm: password,
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: adminUser.id,
role: "admin",
});
await context.agent.post("/login").type("form").send({
type: "password",
email,
password,
});
await context.agent
.post(`/teams/${team.id}?/updateTeam` satisfies Match<RouteId>)
.type("form")
.send({
name: "Updated Team Name",
});
const updatedTeam = await context.admin.collection("teams").getOne(team.id);
expect(updatedTeam.name).not.toBe("Updated Team Name");
context.admin.collection("users").delete(adminUser.id);
});
});
describe("POST /teams/[id]?/removeMember", () => {
it("owner should be able to remove member", async (context) => {
const email = `test-${Math.random().toString(36).slice(2)}@example.com`;
const password = "password";
const memberUser = await context.admin.collection("users").create({
email,
password,
passwordConfirm: password,
});
const member = await context.admin.collection("team_memberships").create({
team: team.id,
user: memberUser.id,
role: "member",
});
await context.agent
.post(`/teams/${team.id}?/removeMember` satisfies Match<RouteId>)
.type("form")
.send({
member_id: member.id,
});
await expect(
context.admin.collection("team_memberships").getOne(member.id),
).rejects.toThrow();
context.admin.collection("users").delete(memberUser.id);
});
it("admin should be able to remove member", async (context) => {
const email = `test-${Math.random().toString(36).slice(2)}@example.com`;
const password = "password";
const adminUser = await context.admin.collection("users").create({
email,
password,
passwordConfirm: password,
});
const memberUser = await context.admin.collection("users").create({
email: `test-${Math.random().toString(36).slice(2)}@example.com`,
password,
passwordConfirm: password,
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: adminUser.id,
role: "admin",
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: memberUser.id,
role: "member",
});
await context.agent.post("/login").type("form").send({
type: "password",
email: adminUser.email,
password,
});
await context.agent
.post(`/teams/${team.id}?/removeMember` satisfies Match<RouteId>)
.type("form")
.send({
member_id: memberUser.id,
});
await expect(
context.admin.collection("team_memberships").getOne(memberUser.id),
).rejects.toThrow();
context.admin.collection("users").delete(adminUser.id);
context.admin.collection("users").delete(memberUser.id);
});
it("admin should not be able to remove owner", async (context) => {
const email = `test-${Math.random().toString(36).slice(2)}@example.com`;
const password = "password";
const adminUser = await context.admin.collection("users").create({
email,
password,
passwordConfirm: password,
});
await context.agent.post("/login").type("form").send({
type: "password",
email: adminUser.email,
password,
});
await context.agent
.post(`/teams/${team.id}?/removeMember` satisfies Match<RouteId>)
.type("form")
.send({
member_id: context.user.id,
});
const teamMembership = await context.admin
.collection("team_memberships")
.getFirstListItem(
context.pb.filter("team = {:team} && user = {:user}", {
team: team.id,
user: context.user.id,
}),
);
expect(teamMembership.role).toBe("owner");
context.admin.collection("users").delete(adminUser.id);
});
});
describe("POST /teams/[id]?/cancelInvite", () => {
it("owner should be able to cancel invite", async (context) => {
const email = `test-${Math.random().toString(36).slice(2)}@example.com`;
const invite = await context.admin.collection("team_invites").create({
team: team.id,
email,
role: "member",
});
await context.agent
.post(`/teams/${team.id}?/cancelInvite` satisfies Match<RouteId>)
.type("form")
.send({
invite_id: invite.id,
});
await expect(
context.admin.collection("team_invites").getOne(invite.id),
).rejects.toThrow();
});
it("admin should be able to cancel invite", async (context) => {
const email = `test-${Math.random().toString(36).slice(2)}@example.com`;
const password = "password";
const adminUser = await context.admin.collection("users").create({
email,
password,
passwordConfirm: password,
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: adminUser.id,
role: "admin",
});
const inviteEmail = `test-${Math.random().toString(36).slice(2)}@example.com`;
const invite = await context.admin.collection("team_invites").create({
team: team.id,
email: inviteEmail,
role: "member",
});
await context.agent.post("/login").type("form").send({
type: "password",
email: adminUser.email,
password,
});
await context.agent
.post(`/teams/${team.id}?/cancelInvite` satisfies Match<RouteId>)
.type("form")
.send({
invite_id: invite.id,
});
await expect(
context.admin.collection("team_invites").getOne(invite.id),
).rejects.toThrow();
context.admin.collection("users").delete(adminUser.id);
});
it("member should not be able to cancel invite", async (context) => {
const email = `test-${Math.random().toString(36).slice(2)}@example.com`;
const password = "password";
const memberUser = await context.admin.collection("users").create({
email,
password,
passwordConfirm: password,
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: memberUser.id,
role: "member",
});
const inviteEmail = `test-${Math.random().toString(36).slice(2)}@example.com`;
const invite = await context.admin.collection("team_invites").create({
team: team.id,
email: inviteEmail,
role: "member",
});
await context.agent.post("/login").type("form").send({
type: "password",
email: memberUser.email,
password,
});
const response = await context.agent
.post(`/teams/${team.id}?/cancelInvite` satisfies Match<RouteId>)
.type("form")
.send({
invite_id: invite.id,
});
expect(response.status).toBe(400);
// Verify invite still exists
const existingInvite = await context.admin
.collection("team_invites")
.getOne(invite.id);
expect(existingInvite).toBeDefined();
context.admin.collection("users").delete(memberUser.id);
});
it("should return error for invalid invite ID", async (context) => {
const response = await context.agent
.post(`/teams/${team.id}?/cancelInvite` satisfies Match<RouteId>)
.type("form")
.send({
invite_id: "invalid-id",
});
expect(response.status).toBe(400);
});
});
describe("POST /teams/[id]?/deleteTeam", () => {
it("owner should be able to delete team", async (context) => {
await context.agent
.post(`/teams/${team.id}?/deleteTeam` satisfies Match<RouteId>)
.type("form")
.send({});
await expect(
context.admin.collection("teams").getOne(team.id),
).rejects.toThrow();
});
it("admin should not be able to delete team", async (context) => {
const email = `test-${Math.random().toString(36).slice(2)}@example.com`;
const password = "password";
const adminUser = await context.admin.collection("users").create({
email,
password,
passwordConfirm: password,
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: adminUser.id,
role: "admin",
});
await context.agent.post("/login").type("form").send({
type: "password",
email: adminUser.email,
password,
});
const response = await context.agent
.post(`/teams/${team.id}?/deleteTeam` satisfies Match<RouteId>)
.type("form")
.send({});
expect(response.status).toBe(400);
// Verify team still exists
const existingTeam = await context.admin
.collection("teams")
.getOne(team.id);
expect(existingTeam).toBeDefined();
context.admin.collection("users").delete(adminUser.id);
});
it("member should not be able to delete team", async (context) => {
const email = `test-${Math.random().toString(36).slice(2)}@example.com`;
const password = "password";
const memberUser = await context.admin.collection("users").create({
email,
password,
passwordConfirm: password,
});
await context.admin.collection("team_memberships").create({
team: team.id,
user: memberUser.id,
role: "member",
});
await context.agent.post("/login").type("form").send({
type: "password",
email: memberUser.email,
password,
});
const response = await context.agent
.post(`/teams/${team.id}?/deleteTeam` satisfies Match<RouteId>)
.type("form")
.send({});
expect(response.status).toBe(400);
// Verify team still exists
const existingTeam = await context.admin
.collection("teams")
.getOne(team.id);
expect(existingTeam).toBeDefined();
context.admin.collection("users").delete(memberUser.id);
});
});
export const load = async ({ locals, parent }) => {
const { user, breadcrumbs } = await parent();
const ownedTeams = await locals.pb.collection("teams").getFullList({
filter: locals.pb.filter("owner = {:owner}", { owner: user.id }),
expand: "team_users_via_team",
});
const memberships = await locals.pb
.collection("team_memberships")
.getFullList({
filter: locals.pb.filter("user = {:user}", { user: user.id }),
expand: "team",
});
const pendingInvites = await locals.pb
.collection("team_invites")
.getFullList({
filter: locals.pb.filter("email = {:email}", { email: user.email }),
expand: "team",
});
const teams = [
...ownedTeams.map((team) => ({
type: "team" as const,
id: team.id,
name: team.name,
role: "owner" as const,
members: team.expand?.team_users_via_team?.length || 0,
})),
...memberships
.filter((membership) => membership.expand?.team.owner !== membership.user)
.map((membership) => ({
type: "membership" as const,
id: membership.team,
name: membership.expand?.team.name,
role: membership.role,
})),
...pendingInvites.map((invite) => ({
type: "invite" as const,
id: invite.team,
name: invite.expand?.team.name,
role: null,
invite_id: invite.id,
})),
];
return {
teams,
breadcrumbs: [...breadcrumbs, { title: "Teams", url: "/teams" }],
};
};
<script lang="ts">
import * as Card from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import UserIcon from "@lucide/svelte/icons/user";
import UserPlusIcon from "@lucide/svelte/icons/user-plus";
import LogOutIcon from "@lucide/svelte/icons/log-out";
import * as Avatar from "$lib/components/ui/avatar";
const roleLabels = {
owner: "Owner",
admin: "Admin",
member: "Member",
};
let { data, children } = $props();
</script>
<section data-slot="content">
<div class="divide-y divide-border">
<div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
<div class="px-4 sm:px-0">
<h2 class="text-base/7 font-semibold text-foreground">
Team Management
</h2>
<p class="mt-1 text-sm/6 text-muted-foreground">
Organize your workspace with teams. Collaborate with members, manage
permissions, and streamline your workflow.
</p>
</div>
<Card.Root class="md:col-span-2">
<Card.Header>
<Card.Title class="text-lg font-semibold">Your Teams</Card.Title>
<Card.Description
>Manage and view your team memberships</Card.Description
>
</Card.Header>
<Card.Content class="space-y-4">
{#if data.teams && data.teams.length > 0}
<div class="grid gap-3">
{#each data.teams as team}
<div
class="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div class="flex items-center space-x-3">
<Avatar.Root class="size-10 rounded-lg">
<Avatar.Fallback
class="rounded-lg bg-primary text-primary-foreground font-medium"
>
{team.name?.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col">
{#if team.role === "owner" || team.role === "admin"}
<a
href="/teams/{team.id}"
class="font-medium text-foreground hover:text-primary transition-colors"
>
{team.name}
</a>
{:else}
<span class="font-medium text-foreground">
{team.name}
</span>
{/if}
<p class="text-sm text-muted-foreground">
{team.role ? roleLabels[team.role] : "Pending"}
</p>
</div>
</div>
{#if team.type === "team"}
<div
class="flex items-center space-x-2 text-sm text-muted-foreground"
>
<UserIcon class="w-4 h-4" />
<span class="font-medium">
{team.members} member{team.members !== 1 ? "s" : ""}
</span>
</div>
{:else if team.type === "membership"}
<div class="flex items-center">
<form method="POST" action="?/leaveTeam">
<input type="hidden" name="team_id" value={team.id} />
<Button type="submit" variant="outline" size="sm">
<LogOutIcon class="w-4 h-4" />
Leave team
</Button>
</form>
</div>
{:else if team.type === "invite"}
<div class="flex items-center">
<form method="POST" action="?/acceptInvite">
<input
type="hidden"
name="invite_id"
value={team.invite_id}
/>
<Button type="submit" variant="outline" size="sm">
<UserPlusIcon class="w-4 h-4" />
Accept invitation
</Button>
</form>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="text-center py-8">
<div
class="mx-auto w-12 h-12 bg-muted rounded-full flex items-center justify-center mb-4"
>
<UserIcon class="w-6 h-6 text-muted-foreground" />
</div>
<h3 class="text-lg font-medium text-foreground mb-2">
No teams yet
</h3>
<p class="text-muted-foreground mb-4">
Get started by creating your first team
</p>
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-between items-center pt-4 border-t">
<div class="text-sm text-muted-foreground">
{data.teams?.length || 0} team{(data.teams?.length || 0) !== 1
? "s"
: ""}
</div>
<Button size="sm" href="/teams/new" class="ml-auto"
>Create new team</Button
>
</Card.Footer>
</Card.Root>
</div>
</div>
</section>
{@render children?.()}
import { error, fail } from "@sveltejs/kit";
import { redirect } from "sveltekit-flash-message/server";
export const actions = {
acceptInvite: async ({ locals, request, cookies }) => {
const user = locals.pb.authStore.record!;
const form = await request.formData();
const inviteId = form.get("invite_id") as string;
if (!inviteId) {
return fail(400, { error: "Invite ID is required" });
}
let invite;
try {
invite = await locals.pb.collection("team_invites").getOne(inviteId);
} catch {
return error(404, { message: "Invite not found" });
}
await locals.admin.collection("team_memberships").create({
user: user.id,
team: invite.team,
role: "member",
});
await locals.admin.collection("team_invites").delete(invite.id);
redirect(303, `/teams`, { type: "toast", message: "Joined team" }, cookies);
},
leaveTeam: async ({ locals, request, cookies }) => {
const user = locals.pb.authStore.record!;
const form = await request.formData();
const teamId = form.get("team_id") as string;
if (!teamId) {
return fail(400, { error: "Team ID is required" });
}
let teamMembership;
try {
teamMembership = await locals.pb
.collection("team_memberships")
.getFirstListItem(
locals.pb.filter("team = {:team} && user = {:user}", {
team: teamId,
user: user.id,
}),
);
} catch {
return error(404, { message: "Team not found" });
}
if (teamMembership.role === "owner") {
return error(400, { message: "You cannot leave a team you own" });
}
await locals.pb.collection("team_memberships").delete(teamMembership.id);
redirect(303, `/teams`, { type: "toast", message: "Left team" }, cookies);
},
};
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Teams",
});
return { ...data, pageMetaTags };
};
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { teamSchema } from "$lib/schemas/team";
import { redirect } from "sveltekit-flash-message/server";
import { setPocketbaseErrors } from "@velastack/pocketbase";
export const load = async () => {
const form = await superValidate(zod4(teamSchema));
return { form };
};
export const actions = {
default: async ({ locals, request, cookies }) => {
const user = locals.pb.authStore.record!;
const form = await superValidate(request, zod4(teamSchema));
if (!form.valid) {
return fail(400, { form });
}
let team;
try {
team = await locals.pb.collection("teams").create({
name: form.data.name,
owner: user.id,
});
} catch (error) {
setPocketbaseErrors(form, error);
return fail(400, { form });
}
await locals.admin.collection("team_memberships").create({
user: user.id,
team: team.id,
role: "owner",
});
await locals.admin.collection("team_invite_links").create({
team: team.id,
name: team.name,
});
redirect("/teams", { type: "toast", message: "Team created" }, cookies);
},
};
<script lang="ts">
import { untrack } from "svelte";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { goto } from "$app/navigation";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { teamSchema } from "$lib/schemas/team";
import * as Form from "$lib/components/ui/form";
let { data } = $props();
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(teamSchema),
},
);
const { form: formData } = form;
const handleOpenChange = (open: boolean) => {
if (!open) {
goto("/teams", { noScroll: true });
}
};
</script>
<Dialog.Root open onOpenChange={handleOpenChange}>
<Dialog.Content>
<form method="POST">
<Dialog.Header class="mb-4">
<Dialog.Title>Create Team</Dialog.Title>
</Dialog.Header>
<Form.Field {form} name="name">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Team Name</Form.Label>
<Input {...props} type="text" bind:value={$formData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Dialog.Footer class="mt-4">
<Form.Button>Create</Form.Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "New team",
});
return { ...data, pageMetaTags };
};
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { Match } from "@velastack/pocketbase";
import * as devalue from "devalue";
import type { RouteId } from "./$types";
let team: { id: string; name: string; owner: string };
beforeEach(async (context) => {
await context.agent.authenticateUser();
});
afterEach(async (context) => {
try {
await context.admin.collection("teams").delete(team.id);
} catch {}
});
describe("GET /teams/new", () => {
it("authenticated user should be able to access new team page", async (context) => {
const response = await context.agent.get(
"/teams/new" satisfies Match<RouteId>,
);
expect(response.status).toBe(200);
});
});
describe("POST /teams/new", () => {
it("authenticated user should be able to create team with valid name", async (context) => {
const teamName = `Test Team ${Math.random().toString(36).slice(2)}`;
await context.agent
.post("/teams/new" satisfies Match<RouteId>)
.type("form")
.send({
name: teamName,
});
// Verify team was created
const teams = await context.admin.collection("teams").getList(1, 1, {
filter: `owner = "${context.user.id}" && name = "${teamName}"`,
});
expect(teams.items).toHaveLength(1);
team = teams.items[0];
expect(team.name).toBe(teamName);
expect(team.owner).toBe(context.user.id);
// Verify team membership was created
const memberships = await context.admin
.collection("team_memberships")
.getList(1, 1, {
filter: `user = "${context.user.id}" && team = "${team.id}"`,
});
expect(memberships.items).toHaveLength(1);
expect(memberships.items[0].role).toBe("owner");
});
it("should return 400 for empty name", async (context) => {
const response = await context.agent
.post("/teams/new" satisfies Match<RouteId>)
.type("form")
.send({
name: "",
});
const data = devalue.parse(response.body.data);
expect(data.form.valid).toBe(false);
});
it("should return 400 for missing name", async (context) => {
const response = await context.agent
.post("/teams/new" satisfies Match<RouteId>)
.type("form")
.send({});
const data = devalue.parse(response.body.data);
expect(data.form.valid).toBe(false);
});
});
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/pocketbase";
import type { RouteId } from "./$types";
describe("GET /teams", () => {
it("authenticated user should be able to access the teams page", async (context) => {
await context.agent.authenticateUser();
const response = await context.agent.get("/teams" satisfies Match<RouteId>);
expect(response.status).toBe(200);
});
});
import { error, redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";
export const load = async ({ params, cookies, url, locals }) => {
const { id } = params;
const redirectUrl = url.searchParams.get("redirect") ?? "/dashboard";
try {
await locals.pb.collection("teams").getOne(id);
} catch {
return error(404, { message: "Team not found" });
}
cookies.set("team", id, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: !dev,
maxAge: 60 * 60 * 24 * 30,
});
return redirect(303, redirectUrl);
};
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { Match } from "@velastack/pocketbase";
import type { RouteId } from "./$types";
let team: { id: string; name: string; owner: string };
beforeEach(async (context) => {
await context.agent.authenticateUser();
// Create a test team for the user
const teamName = `Test Team ${Math.random().toString(36).slice(2)}`;
team = await context.admin.collection("teams").create({
name: teamName,
owner: context.user.id,
});
// Create team membership
await context.admin.collection("team_memberships").create({
user: context.user.id,
team: team.id,
role: "owner",
});
});
afterEach(async (context) => {
try {
await context.admin.collection("teams").delete(team.id);
} catch {}
});
describe("GET /teams/switch/[id]", () => {
it("should set team cookie and redirect to dashboard for valid team", async (context) => {
const response = await context.agent.get(
`/teams/switch/${team.id}` satisfies Match<RouteId>,
);
expect(response.status).toBe(303);
expect(response.headers.location).toBe("/dashboard");
// Check that the team cookie is set
const cookies = response.headers["set-cookie"]?.[0];
expect(cookies).toBeDefined();
expect(cookies).toContain(`team=${team.id}`);
});
it("should redirect to custom redirect URL when provided", async (context) => {
const customRedirect = "/teams";
const response = await context.agent.get(
`/teams/switch/${team.id}?redirect=${encodeURIComponent(customRedirect)}` satisfies Match<RouteId>,
);
expect(response.status).toBe(303);
expect(response.headers.location).toBe(customRedirect);
// Check that the team cookie is still set
const cookies = response.headers["set-cookie"]?.[0];
expect(cookies).toBeDefined();
expect(cookies).toContain("team=" + team.id);
});
it("should return 404 error for non-existent team", async (context) => {
const nonExistentTeamId = "non-existent-team-id";
const response = await context.agent.get(
`/teams/switch/${nonExistentTeamId}` satisfies Match<RouteId>,
);
expect(response.status).toBe(404);
const cookies = response.headers["set-cookie"]?.[0];
expect(cookies).toBeUndefined();
});
it("should return 404 error for invalid team ID format", async (context) => {
const invalidTeamId = "invalid-id-format";
const response = await context.agent.get(
`/teams/switch/${invalidTeamId}` satisfies Match<RouteId>,
);
expect(response.status).toBe(404);
// Check that no team cookie is set
const cookies = response.headers["set-cookie"]?.[0];
expect(cookies).toBeUndefined();
});
});
<script lang="ts">
import type { ComponentProps } from "svelte";
import HomeIcon from "@lucide/svelte/icons/home";
import * as Sidebar from "$lib/components/ui/sidebar";
import NavMain from "./nav-main.svelte";
import NavUser from "./nav-user.svelte";
import TeamSwitcher from "$lib/components/team-switcher.svelte";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import * as Avatar from "$lib/components/ui/avatar";
let data = {
navMain: [
{
title: "Dashboard",
url: "/dashboard",
icon: HomeIcon,
},
],
};
let {
user,
meta,
team,
teams = [],
ref = $bindable(null),
...restProps
}: ComponentProps<typeof Sidebar.Root> & {
user: any;
meta: any;
team?: string | undefined;
teams?: { id: string; name: string }[];
} = $props();
</script>
<Sidebar.Root bind:ref variant="inset" {...restProps}>
<Sidebar.Header>
<Sidebar.Menu>
<TeamSwitcher {teams}>
{#snippet children()}
{@const activeTeam = teams.find((t) => t.id === team)}
{#if activeTeam}
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Fallback
class="rounded-lg bg-primary text-primary-foreground font-medium"
>
{activeTeam.name?.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">
{activeTeam.name}
</span>
</div>
{:else}
<div
class="bg-sidebar-accent text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
>
<img src="/favicon.svg" alt="logo" class="size-6" />
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{meta.appName}</span>
</div>
{/if}
<ChevronsUpDownIcon class="ml-auto" />
{/snippet}
</TeamSwitcher>
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content>
<NavMain items={data.navMain} />
</Sidebar.Content>
<Sidebar.Footer>
<NavUser {user} />
</Sidebar.Footer>
</Sidebar.Root>
<script lang="ts">
import { toggleMode, mode } from "mode-watcher";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import Settings2Icon from "@lucide/svelte/icons/settings-2";
import UsersIcon from "@lucide/svelte/icons/users";
import LogOutIcon from "@lucide/svelte/icons/log-out";
import MoonIcon from "@lucide/svelte/icons/moon";
import SunIcon from "@lucide/svelte/icons/sun";
import * as Avatar from "$lib/components/ui/avatar";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import * as Sidebar from "$lib/components/ui/sidebar";
let data = {
navUser: [
{
title: "Settings",
url: "/settings",
icon: Settings2Icon,
},
{
title: "Teams",
url: "/teams",
icon: UsersIcon,
},
],
};
let {
user,
}: {
user: {
id: string;
name: string;
email: string;
avatar: string;
};
} = $props();
const sidebar = Sidebar.useSidebar();
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
{...props}
>
<Avatar.Root class="size-8 rounded-lg">
{#if user.avatar}
<Avatar.Image
src="/api/files/users/{user.id}/{user.avatar}"
alt={user.name}
/>
{/if}
<Avatar.Fallback class="rounded-lg">
{user.name?.charAt(0).toUpperCase() ||
user.email?.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{user.name || user.email}</span
>
</div>
<ChevronsUpDownIcon class="ml-auto size-4" />
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
side={sidebar.isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="size-8 rounded-lg">
{#if user.avatar}
<Avatar.Image
src="/api/files/users/{user.id}/{user.avatar}"
alt={user.name}
/>
{/if}
<Avatar.Fallback class="rounded-lg">
{user.name?.charAt(0).toUpperCase() ||
user.email?.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{user.name || user.email}</span
>
</div>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item onclick={toggleMode}>
{#if mode.current === "dark"}
<SunIcon />
Light mode
{:else}
<MoonIcon />
Dark mode
{/if}
</DropdownMenu.Item>
{#each data.navUser as item}
<DropdownMenu.Item class="p-0">
<a
href={item.url}
class="flex cursor-default items-center gap-2 px-2 py-1.5 w-full"
>
<item.icon />
{item.title}
</a>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item class="p-0">
<form action="/logout" method="post" class="w-full">
<button
type="submit"
class="flex cursor-default items-center gap-2 px-2 py-1.5 w-full"
>
<LogOutIcon />
Log out
</button>
</form>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
export const load = async ({ locals, depends }) => {
const user = locals.pb.authStore.record!;
const team = locals.team;
const teams = await locals.pb.collection("teams").getFullList();
depends("app:team");
const breadcrumbs = [{ title: "Home", url: "/dashboard" }];
return { user, team, teams, breadcrumbs };
};
<script lang="ts">
import * as Sidebar from "$lib/components/ui/sidebar";
import { Separator } from "$lib/components/ui/separator";
import AppSidebar from "$lib/components/app-sidebar.svelte";
import * as Breadcrumb from "$lib/components/ui/breadcrumb";
import { page } from "$app/state";
let breadcrumbs = $derived(page.data.breadcrumbs || []) as {
title: string;
url: string;
}[];
let { data, children } = $props();
</script>
<div class="min-h-dvh dashboard">
<Sidebar.Provider>
<AppSidebar
user={data.user}
meta={data.meta}
team={data.team}
teams={data.teams ?? []}
/>
<Sidebar.Inset>
<header class="flex h-16 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1" />
<Separator
orientation="vertical"
class="mr-2 data-[orientation=vertical]:h-4"
/>
{#if breadcrumbs.length > 0}
<Breadcrumb.Root>
<Breadcrumb.List>
{#if breadcrumbs.length > 1}
{#each breadcrumbs.slice(0, -1) as breadcrumb}
<Breadcrumb.Item>
<Breadcrumb.Link href={breadcrumb.url}
>{breadcrumb.title}</Breadcrumb.Link
>
</Breadcrumb.Item>
<Breadcrumb.Separator />
{/each}
{/if}
<Breadcrumb.Page>
{breadcrumbs[breadcrumbs.length - 1].title}
</Breadcrumb.Page>
</Breadcrumb.List>
</Breadcrumb.Root>
{/if}
</div>
</header>
<div class="flex flex-1 flex-col p-4 pt-0">
{@render children?.()}
</div>
</Sidebar.Inset>
</Sidebar.Provider>
</div>
<style>
:global(body:has(div.dashboard)) {
background-color: var(--color-sidebar);
}
</style>
teamsteam_membershipsteam_usersteam_invitesteam_invite_links