Logo VelaStack

Enable teams

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.

$ vela enable teams
src/lib/components/team-switcher.svelte
<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>
src/lib/schemas/team.ts
import { z } from "zod";

export const teamSchema = z.object({
  name: z.string(),
});
src/lib/schemas/teamInvite.ts
import { z } from "zod";

export const teamInviteSchema = z.object({
  email: z.email(),
});
src/lib/schemas/teamRole.ts
import { z } from "zod";

export const teamRoleSchema = z.object({
  role: z.enum(["owner", "admin", "member"]),
});
src/routes/(app)/invite/[id]/+layout@.svelte
<script lang="ts">
  import RootLayout from "../../../(public)/root-layout.svelte";

  let { data, children } = $props();
</script>

<RootLayout {data}>
  {@render children?.()}
</RootLayout>
src/routes/(app)/invite/[id]/+page.server.ts
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,
    );
  },
};
src/routes/(app)/invite/[id]/+page.svelte
<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>
src/routes/(app)/invite/[id]/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Invitation",
  });

  return { ...data, pageMetaTags };
};
src/routes/(app)/join/[id]/+layout@.svelte
<script lang="ts">
  import RootLayout from "../../../(public)/root-layout.svelte";

  let { data, children } = $props();
</script>

<RootLayout {data}>
  {@render children?.()}
</RootLayout>
src/routes/(app)/join/[id]/+page.server.ts
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,
    );
  },
};
src/routes/(app)/join/[id]/+page.svelte
<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>
src/routes/(app)/join/[id]/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Join team",
  });

  return { ...data, pageMetaTags };
};
src/routes/(app)/teams/[id]/+layout.server.ts
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}` },
    ],
  };
};
src/routes/(app)/teams/[id]/+layout@(app).svelte
<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?.()}
src/routes/(app)/teams/[id]/+page.server.ts
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,
    );
  },
};
src/routes/(app)/teams/[id]/+page.svelte
src/routes/(app)/teams/[id]/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Team",
  });

  return { ...data, pageMetaTags };
};
src/routes/(app)/teams/[id]/authorization.ts
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,
  };
};
src/routes/(app)/teams/[id]/invite/+page.server.ts
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,
    );
  },
};
src/routes/(app)/teams/[id]/invite/+page.svelte
<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>
src/routes/(app)/teams/[id]/invite/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Invite to team",
  });

  return { ...data, pageMetaTags };
};
src/routes/(app)/teams/[id]/invite/server.test.ts
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);
  });
});
src/routes/(app)/teams/[id]/member/[member_id]/+page.server.ts
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,
    );
  },
};
src/routes/(app)/teams/[id]/member/[member_id]/+page.svelte
<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>
src/routes/(app)/teams/[id]/member/[member_id]/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Team member",
  });

  return { ...data, pageMetaTags };
};
src/routes/(app)/teams/[id]/member/[member_id]/server.test.ts
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);
  });
});
src/routes/(app)/teams/[id]/server.test.ts
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);
  });
});
src/routes/(app)/teams/+layout.server.ts
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" }],
  };
};
src/routes/(app)/teams/+layout.svelte
<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?.()}
src/routes/(app)/teams/+page.server.ts
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);
  },
};
src/routes/(app)/teams/+page.svelte
src/routes/(app)/teams/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Teams",
  });

  return { ...data, pageMetaTags };
};
src/routes/(app)/teams/new/+page.server.ts
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);
  },
};
src/routes/(app)/teams/new/+page.svelte
<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>
src/routes/(app)/teams/new/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "New team",
  });

  return { ...data, pageMetaTags };
};
src/routes/(app)/teams/new/server.test.ts
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);
  });
});
src/routes/(app)/teams/server.test.ts
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);
  });
});
src/routes/(app)/teams/switch/[id]/+page.server.ts
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);
};
src/routes/(app)/teams/switch/[id]/server.test.ts
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();
  });
});