forked from wrenn/wrenn
Add team management frontend
- New /dashboard/team page with inline team name editing, slug/ID copy, members table with split-button (remove + make admin/member), add member typeahead, and danger zone (delete/leave) with confirmation dialogs - Sidebar now fetches real teams from API, supports team switching and team creation via dialog - Rename nav item Members → Team, route /dashboard/members → /dashboard/team - New src/lib/api/team.ts with typed functions for all team endpoints
This commit is contained in:
83
frontend/src/lib/api/team.ts
Normal file
83
frontend/src/lib/api/team.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { apiFetch, type ApiResult } from '$lib/api/client';
|
||||||
|
|
||||||
|
export type TeamMember = {
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
role: 'owner' | 'admin' | 'member';
|
||||||
|
joined_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamInfo = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamDetail = {
|
||||||
|
team: TeamInfo;
|
||||||
|
members: TeamMember[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserSearchResult = {
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamWithRole = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
created_at: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listTeams(): Promise<ApiResult<TeamWithRole[]>> {
|
||||||
|
return apiFetch('GET', '/api/v1/teams');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTeam(name: string): Promise<ApiResult<TeamWithRole>> {
|
||||||
|
return apiFetch('POST', '/api/v1/teams', { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchTeam(
|
||||||
|
teamId: string
|
||||||
|
): Promise<ApiResult<{ token: string; user_id: string; team_id: string; email: string }>> {
|
||||||
|
return apiFetch('POST', '/api/v1/auth/switch-team', { team_id: teamId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeam(id: string): Promise<ApiResult<TeamDetail>> {
|
||||||
|
return apiFetch('GET', `/api/v1/teams/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTeam(id: string, name: string): Promise<ApiResult<void>> {
|
||||||
|
return apiFetch('PATCH', `/api/v1/teams/${id}`, { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMember(id: string, email: string): Promise<ApiResult<TeamMember>> {
|
||||||
|
return apiFetch('POST', `/api/v1/teams/${id}/members`, { email });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeMember(id: string, userId: string): Promise<ApiResult<void>> {
|
||||||
|
return apiFetch('DELETE', `/api/v1/teams/${id}/members/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMemberRole(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
role: 'admin' | 'member'
|
||||||
|
): Promise<ApiResult<void>> {
|
||||||
|
return apiFetch('PATCH', `/api/v1/teams/${id}/members/${userId}`, { role });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTeam(id: string): Promise<ApiResult<void>> {
|
||||||
|
return apiFetch('DELETE', `/api/v1/teams/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function leaveTeam(id: string): Promise<ApiResult<void>> {
|
||||||
|
return apiFetch('POST', `/api/v1/teams/${id}/leave`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchUsers(email: string): Promise<ApiResult<UserSearchResult[]>> {
|
||||||
|
return apiFetch('GET', `/api/v1/users/search?email=${encodeURIComponent(email)}`);
|
||||||
|
}
|
||||||
@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { Popover } from 'bits-ui';
|
import { Popover } from 'bits-ui';
|
||||||
import { auth } from '$lib/auth.svelte';
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
import { listTeams, createTeam, switchTeam, type TeamWithRole } from '$lib/api/team';
|
||||||
import {
|
import {
|
||||||
IconMonitor,
|
IconMonitor,
|
||||||
IconBox,
|
IconBox,
|
||||||
@ -23,8 +25,16 @@
|
|||||||
|
|
||||||
let teamPopoverOpen = $state(false);
|
let teamPopoverOpen = $state(false);
|
||||||
|
|
||||||
const currentTeam = 'default';
|
// Real teams from API
|
||||||
const userName = $derived(auth.email ?? '');
|
let teams = $state<TeamWithRole[]>([]);
|
||||||
|
let currentTeamName = $derived(teams.find((t) => t.id === auth.teamId)?.name ?? '');
|
||||||
|
let userName = $derived(auth.email ?? '');
|
||||||
|
|
||||||
|
// Create team dialog
|
||||||
|
let showCreateTeam = $state(false);
|
||||||
|
let newTeamName = $state('');
|
||||||
|
let creatingTeam = $state(false);
|
||||||
|
let createTeamError = $state<string | null>(null);
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
label: string;
|
label: string;
|
||||||
@ -39,7 +49,7 @@
|
|||||||
|
|
||||||
const managementItems: NavItem[] = [
|
const managementItems: NavItem[] = [
|
||||||
{ label: 'Keys', icon: IconKey, href: '/dashboard/keys' },
|
{ label: 'Keys', icon: IconKey, href: '/dashboard/keys' },
|
||||||
{ label: 'Members', icon: IconMembers, href: '/dashboard/members' },
|
{ label: 'Team', icon: IconMembers, href: '/dashboard/team' },
|
||||||
{ label: 'Audit Logs', icon: IconAudit, href: '/dashboard/audit' }
|
{ label: 'Audit Logs', icon: IconAudit, href: '/dashboard/audit' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -48,8 +58,6 @@
|
|||||||
{ label: 'Billing', icon: IconBilling, href: '/dashboard/billing' }
|
{ label: 'Billing', icon: IconBilling, href: '/dashboard/billing' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const teams = ['default', 'Wrenn Labs', 'Acme Corp'];
|
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
function isActive(href: string): boolean {
|
||||||
const p = $page.url.pathname;
|
const p = $page.url.pathname;
|
||||||
return p === href || p.startsWith(href + '/');
|
return p === href || p.startsWith(href + '/');
|
||||||
@ -59,6 +67,48 @@
|
|||||||
collapsed = !collapsed;
|
collapsed = !collapsed;
|
||||||
localStorage.setItem('wrenn_sidebar_collapsed', String(collapsed));
|
localStorage.setItem('wrenn_sidebar_collapsed', String(collapsed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchTeams() {
|
||||||
|
const result = await listTeams();
|
||||||
|
if (result.ok) {
|
||||||
|
teams = result.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSwitchTeam(teamId: string) {
|
||||||
|
if (teamId === auth.teamId) {
|
||||||
|
teamPopoverOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
teamPopoverOpen = false;
|
||||||
|
const result = await switchTeam(teamId);
|
||||||
|
if (result.ok) {
|
||||||
|
auth.login(result.data);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateTeam() {
|
||||||
|
if (!newTeamName.trim()) return;
|
||||||
|
creatingTeam = true;
|
||||||
|
createTeamError = null;
|
||||||
|
const result = await createTeam(newTeamName.trim());
|
||||||
|
if (result.ok) {
|
||||||
|
const switchResult = await switchTeam(result.data.id);
|
||||||
|
if (switchResult.ok) {
|
||||||
|
auth.login(switchResult.data);
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
createTeamError = switchResult.error;
|
||||||
|
creatingTeam = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
createTeamError = result.error;
|
||||||
|
creatingTeam = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(fetchTeams);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
@ -97,7 +147,7 @@
|
|||||||
<div
|
<div
|
||||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-[var(--radius-avatar)] bg-[var(--color-bg-4)] text-badge font-bold uppercase text-[var(--color-text-secondary)]"
|
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-[var(--radius-avatar)] bg-[var(--color-bg-4)] text-badge font-bold uppercase text-[var(--color-text-secondary)]"
|
||||||
>
|
>
|
||||||
{currentTeam[0]}
|
{(currentTeamName || '?')[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<div class="min-w-0 flex-1 overflow-hidden whitespace-nowrap">
|
<div class="min-w-0 flex-1 overflow-hidden whitespace-nowrap">
|
||||||
@ -107,7 +157,7 @@
|
|||||||
Team
|
Team
|
||||||
</div>
|
</div>
|
||||||
<div class="truncate text-ui text-[var(--color-text-primary)]">
|
<div class="truncate text-ui text-[var(--color-text-primary)]">
|
||||||
{currentTeam}
|
{currentTeamName || '…'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<IconChevron
|
<IconChevron
|
||||||
@ -130,33 +180,39 @@
|
|||||||
>
|
>
|
||||||
Teams
|
Teams
|
||||||
</div>
|
</div>
|
||||||
{#each teams as team}
|
{#each teams as team (team.id)}
|
||||||
<button
|
<button
|
||||||
class="flex w-full items-center gap-2.5 rounded-[var(--radius-input)] px-2.5 py-2 text-ui transition-colors duration-150 hover:bg-[var(--color-bg-3)] {team ===
|
class="flex w-full items-center gap-2.5 rounded-[var(--radius-input)] px-2.5 py-2 text-ui transition-colors duration-150 hover:bg-[var(--color-bg-3)] {team.id ===
|
||||||
currentTeam
|
auth.teamId
|
||||||
? 'bg-[var(--color-accent-glow)]'
|
? 'bg-[var(--color-accent-glow)]'
|
||||||
: ''}"
|
: ''}"
|
||||||
onclick={() => (teamPopoverOpen = false)}
|
onclick={() => handleSwitchTeam(team.id)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-5 w-5 items-center justify-center rounded-[var(--radius-avatar)] text-badge font-bold uppercase text-white {team ===
|
class="flex h-5 w-5 items-center justify-center rounded-[var(--radius-avatar)] text-badge font-bold uppercase text-white {team.id ===
|
||||||
currentTeam
|
auth.teamId
|
||||||
? 'bg-[var(--color-accent)]'
|
? 'bg-[var(--color-accent)]'
|
||||||
: 'bg-[var(--color-bg-5)]'}"
|
: 'bg-[var(--color-bg-5)]'}"
|
||||||
>
|
>
|
||||||
{team[0]}
|
{team.name[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class={team === currentTeam
|
class={team.id === auth.teamId
|
||||||
? 'font-medium text-[var(--color-text-bright)]'
|
? 'font-medium text-[var(--color-text-bright)]'
|
||||||
: 'text-[var(--color-text-primary)]'}
|
: 'text-[var(--color-text-primary)]'}
|
||||||
>
|
>
|
||||||
{team}
|
{team.name}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="mt-0.5 border-t border-[var(--color-border)] pt-0.5">
|
<div class="mt-0.5 border-t border-[var(--color-border)] pt-0.5">
|
||||||
<button
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
teamPopoverOpen = false;
|
||||||
|
newTeamName = '';
|
||||||
|
createTeamError = null;
|
||||||
|
showCreateTeam = true;
|
||||||
|
}}
|
||||||
class="flex w-full items-center gap-2.5 rounded-[var(--radius-input)] px-2.5 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)]"
|
class="flex w-full items-center gap-2.5 rounded-[var(--radius-input)] px-2.5 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)]"
|
||||||
>
|
>
|
||||||
<IconPlus size={14} />
|
<IconPlus size={14} />
|
||||||
@ -293,6 +349,79 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- Create Team Dialog -->
|
||||||
|
{#if showCreateTeam}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/60"
|
||||||
|
onclick={() => { if (!creatingTeam) showCreateTeam = false; }}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape' && !creatingTeam) showCreateTeam = false; }}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
|
||||||
|
style="animation: fadeUp 0.2s ease both"
|
||||||
|
>
|
||||||
|
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">
|
||||||
|
Create Team
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
|
||||||
|
Choose a name for your new team.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if createTeamError}
|
||||||
|
<div
|
||||||
|
class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]"
|
||||||
|
>
|
||||||
|
{createTeamError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<label
|
||||||
|
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
|
||||||
|
for="new-team-name"
|
||||||
|
>
|
||||||
|
Team name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-team-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Acme Engineering"
|
||||||
|
bind:value={newTeamName}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter' && !creatingTeam) handleCreateTeam(); }}
|
||||||
|
disabled={creatingTeam}
|
||||||
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onclick={() => { showCreateTeam = false; }}
|
||||||
|
disabled={creatingTeam}
|
||||||
|
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleCreateTeam}
|
||||||
|
disabled={creatingTeam || !newTeamName.trim()}
|
||||||
|
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||||
|
>
|
||||||
|
{#if creatingTeam}
|
||||||
|
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
Creating...
|
||||||
|
{:else}
|
||||||
|
Create Team
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@keyframes popoverSlideIn {
|
@keyframes popoverSlideIn {
|
||||||
|
|||||||
1196
frontend/src/routes/dashboard/team/+page.svelte
Normal file
1196
frontend/src/routes/dashboard/team/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user