1
0
forked from wrenn/wrenn

Add user names, team-scoped sandbox guard, and login robustness fixes

- Add name column to users (migration + sqlc regen); propagate through JWT
  claims, auth context, all auth/OAuth handlers, service layer, and frontend
- Sidebar and team page show name instead of email; team page splits Name/Email
  into separate columns
- Block sandbox creation in UI and API when user has no active team context
- loginTeam helper falls back to first active team when no default is set,
  fixing login for invited users with no is_default membership
- Exclude soft-deleted teams from GetDefaultTeamForUser, GetBYOCTeams queries
- Guard host creation against soft-deleted teams in service/host.go
- SwitchTeam re-fetches name from DB instead of trusting stale JWT claim
- Reset teams store on login so stale data from a previous session never persists
- Update openapi.yaml: add name to SignupRequest and AuthResponse schemas
This commit is contained in:
2026-03-24 16:56:10 +06:00
parent aaeccd32ce
commit 3932bc056e
26 changed files with 228 additions and 77 deletions

View File

@ -3,6 +3,7 @@ export type AuthResponse = {
user_id: string;
team_id: string;
email: string;
name: string;
};
export type AuthResult = { ok: true; data: AuthResponse } | { ok: false; error: string };
@ -11,8 +12,8 @@ export async function apiLogin(email: string, password: string): Promise<AuthRes
return authFetch('/api/v1/auth/login', { email, password });
}
export async function apiSignup(email: string, password: string): Promise<AuthResult> {
return authFetch('/api/v1/auth/signup', { email, password });
export async function apiSignup(email: string, password: string, name: string): Promise<AuthResult> {
return authFetch('/api/v1/auth/signup', { email, password, name });
}
async function authFetch(url: string, body: Record<string, string>): Promise<AuthResult> {

View File

@ -2,6 +2,7 @@ import { apiFetch, type ApiResult } from '$lib/api/client';
export type TeamMember = {
user_id: string;
name: string;
email: string;
role: 'owner' | 'admin' | 'member';
joined_at: string;
@ -42,7 +43,7 @@ export async function createTeam(name: string): Promise<ApiResult<TeamWithRole>>
export async function switchTeam(
teamId: string
): Promise<ApiResult<{ token: string; user_id: string; team_id: string; email: string }>> {
): Promise<ApiResult<{ token: string; user_id: string; team_id: string; email: string; name: string }>> {
return apiFetch('POST', '/api/v1/auth/switch-team', { team_id: teamId });
}

View File

@ -4,7 +4,8 @@ const STORAGE_KEYS = {
token: 'wrenn_token',
userId: 'wrenn_user_id',
teamId: 'wrenn_team_id',
email: 'wrenn_email'
email: 'wrenn_email',
name: 'wrenn_name'
} as const;
function isTokenExpired(token: string): boolean {
@ -23,6 +24,7 @@ function createAuth() {
let userId = $state<string | null>(null);
let teamId = $state<string | null>(null);
let email = $state<string | null>(null);
let name = $state<string | null>(null);
let initialized = $state(false);
// Initialize from localStorage synchronously at module load.
@ -33,6 +35,7 @@ function createAuth() {
userId = localStorage.getItem(STORAGE_KEYS.userId);
teamId = localStorage.getItem(STORAGE_KEYS.teamId);
email = localStorage.getItem(STORAGE_KEYS.email);
name = localStorage.getItem(STORAGE_KEYS.name);
} else if (stored) {
// Expired — clean up.
for (const key of Object.values(STORAGE_KEYS)) {
@ -57,6 +60,9 @@ function createAuth() {
get email() {
return email;
},
get name() {
return name;
},
get isAuthenticated() {
return isAuthenticated;
},
@ -64,16 +70,18 @@ function createAuth() {
return initialized;
},
login(data: { token: string; user_id: string; team_id: string; email: string }) {
login(data: { token: string; user_id: string; team_id: string; email: string; name: string }) {
token = data.token;
userId = data.user_id;
teamId = data.team_id;
email = data.email;
name = data.name;
localStorage.setItem(STORAGE_KEYS.token, data.token);
localStorage.setItem(STORAGE_KEYS.userId, data.user_id);
localStorage.setItem(STORAGE_KEYS.teamId, data.team_id);
localStorage.setItem(STORAGE_KEYS.email, data.email);
localStorage.setItem(STORAGE_KEYS.name, data.name);
},
logout() {
@ -81,6 +89,7 @@ function createAuth() {
userId = null;
teamId = null;
email = null;
name = null;
for (const key of Object.values(STORAGE_KEYS)) {
localStorage.removeItem(key);

View File

@ -27,7 +27,7 @@
let teamPopoverOpen = $state(false);
let currentTeamName = $derived(teamsStore.list.find((t) => t.id === auth.teamId)?.name ?? '');
let userName = $derived(auth.email ?? '');
let userName = $derived(auth.name || auth.email || '');
// Create team dialog
let showCreateTeam = $state(false);

View File

@ -24,6 +24,10 @@ function createTeamsStore() {
set(newTeams: TeamWithRole[]) {
teams = newTeams;
loaded = true;
},
reset() {
teams = [];
loaded = false;
}
};
}

View File

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { auth } from '$lib/auth.svelte';
import { teams } from '$lib/teams.svelte';
const params = $page.url.searchParams;
const error = params.get('error');
@ -13,9 +14,11 @@
const userId = params.get('user_id');
const teamId = params.get('team_id');
const email = params.get('email');
const name = params.get('name') ?? '';
if (token && userId && teamId && email) {
auth.login({ token, user_id: userId, team_id: teamId, email });
teams.reset();
auth.login({ token, user_id: userId, team_id: teamId, email, name });
goto('/dashboard');
} else {
goto('/login?error=missing_token');

View File

@ -2,6 +2,7 @@
import Sidebar from '$lib/components/Sidebar.svelte';
import { onMount } from 'svelte';
import { toast } from '$lib/toast.svelte';
import { auth } from '$lib/auth.svelte';
import {
listCapsules,
createCapsule,
@ -440,7 +441,9 @@
<button
onclick={() => { showCreateDialog = true; createError = null; }}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
disabled={!auth.teamId}
title={!auth.teamId ? 'No active team — re-authenticate to create capsules' : undefined}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:pointer-events-none disabled:opacity-40"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
@ -494,7 +497,8 @@
</p>
<button
onclick={() => { showCreateDialog = true; createError = null; }}
class="mt-6 flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2.5 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
disabled={!auth.teamId}
class="mt-6 flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2.5 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:pointer-events-none disabled:opacity-40"
>
Launch a Capsule
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">

View File

@ -209,8 +209,8 @@
);
toast.success(
newRole === 'admin'
? `${member.email} is now an admin`
: `${member.email} is now a member`
? `${member.name || member.email} is now an admin`
: `${member.name || member.email} is now a member`
);
} else {
toast.error(result.error);
@ -675,12 +675,17 @@
>
<!-- Table header -->
<div
class="grid grid-cols-[1fr_120px_140px_120px] border-b border-[var(--color-border)] bg-[var(--color-bg-3)]"
class="grid grid-cols-[1fr_1fr_120px_140px_120px] border-b border-[var(--color-border)] bg-[var(--color-bg-3)]"
>
<div
class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
>
Member
Name
</div>
<div
class="px-4 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
>
Email
</div>
<div
class="px-4 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
@ -699,15 +704,15 @@
{#each members as member, i (member.user_id)}
<div
class="grid grid-cols-[1fr_120px_140px_120px] items-center border-b border-[var(--color-border)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] last:border-b-0 {recentlyAddedId === member.user_id ? 'member-flash' : ''}"
class="grid grid-cols-[1fr_1fr_120px_140px_120px] items-center border-b border-[var(--color-border)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] last:border-b-0 {recentlyAddedId === member.user_id ? 'member-flash' : ''}"
in:fly={{ y: 6, duration: 200, delay: i * 30, easing: cubicOut }}
out:fly={{ x: -16, duration: 180, easing: cubicOut }}
>
<!-- Email -->
<!-- Name -->
<div class="min-w-0 px-5 py-4">
<div class="flex min-w-0 items-center gap-2">
<span class="truncate text-ui text-[var(--color-text-bright)]"
>{member.email}</span
>{member.name || member.email}</span
>
{#if member.user_id === auth.userId}
<span class="shrink-0 rounded-[2px] bg-[var(--color-bg-4)] px-1.5 py-0.5 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">you</span>
@ -715,6 +720,11 @@
</div>
</div>
<!-- Email -->
<div class="min-w-0 px-4 py-4">
<span class="truncate font-mono text-ui text-[var(--color-text-secondary)]">{member.email}</span>
</div>
<!-- Role badge -->
<div class="px-4 py-4">
{#if updatingRoleId === member.user_id}
@ -1092,7 +1102,7 @@
</h2>
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
Remove <span class="font-medium text-[var(--color-text-secondary)]"
>{removeTarget.email}</span
>{removeTarget.name || removeTarget.email}</span
> from the team? They will lose access immediately.
</p>

View File

@ -3,11 +3,13 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { auth } from '$lib/auth.svelte';
import { teams } from '$lib/teams.svelte';
import { apiLogin, apiSignup } from '$lib/api/auth';
import {
IconGithub,
IconMail,
IconLock,
IconUser,
IconEye,
IconEyeOff
} from '$lib/components/icons';
@ -15,6 +17,7 @@
let mode: 'signin' | 'signup' = $state('signin');
let email = $state('');
let password = $state('');
let name = $state('');
let showPassword = $state(false);
let error = $state('');
let loading = $state(false);
@ -77,6 +80,7 @@
function switchMode() {
mode = mode === 'signin' ? 'signup' : 'signin';
error = '';
name = '';
}
async function handleSubmit(e: Event) {
@ -87,7 +91,7 @@
const result =
mode === 'signin'
? await apiLogin(email, password)
: await apiSignup(email, password);
: await apiSignup(email, password, name);
loading = false;
@ -96,6 +100,7 @@
return;
}
teams.reset();
auth.login(result.data);
goto('/dashboard');
}
@ -204,6 +209,22 @@
<!-- Form -->
<form onsubmit={handleSubmit} class="space-y-3">
{#if mode === 'signup'}
<div class="group relative">
<div
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]"
>
<IconUser size={14} />
</div>
<input
type="text"
bind:value={name}
placeholder="Full name"
autocomplete="name"
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-3 pl-9 pr-3 text-body text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
/>
</div>
{/if}
<div class="group relative">
<div
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]"