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:
5
db/migrations/20260324100234_user_names.sql
Normal file
5
db/migrations/20260324100234_user_names.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE users DROP COLUMN name;
|
||||||
@ -13,14 +13,14 @@ VALUES ($1, $2, $3, $4);
|
|||||||
-- name: GetDefaultTeamForUser :one
|
-- name: GetDefaultTeamForUser :one
|
||||||
SELECT t.* FROM teams t
|
SELECT t.* FROM teams t
|
||||||
JOIN users_teams ut ON ut.team_id = t.id
|
JOIN users_teams ut ON ut.team_id = t.id
|
||||||
WHERE ut.user_id = $1 AND ut.is_default = TRUE
|
WHERE ut.user_id = $1 AND ut.is_default = TRUE AND t.deleted_at IS NULL
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: SetTeamBYOC :exec
|
-- name: SetTeamBYOC :exec
|
||||||
UPDATE teams SET is_byoc = $2 WHERE id = $1;
|
UPDATE teams SET is_byoc = $2 WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetBYOCTeams :many
|
-- name: GetBYOCTeams :many
|
||||||
SELECT * FROM teams WHERE is_byoc = TRUE ORDER BY created_at;
|
SELECT * FROM teams WHERE is_byoc = TRUE AND deleted_at IS NULL ORDER BY created_at;
|
||||||
|
|
||||||
-- name: GetTeamMembership :one
|
-- name: GetTeamMembership :one
|
||||||
SELECT * FROM users_teams WHERE user_id = $1 AND team_id = $2;
|
SELECT * FROM users_teams WHERE user_id = $1 AND team_id = $2;
|
||||||
@ -42,7 +42,7 @@ WHERE ut.user_id = $1 AND t.deleted_at IS NULL
|
|||||||
ORDER BY ut.created_at;
|
ORDER BY ut.created_at;
|
||||||
|
|
||||||
-- name: GetTeamMembers :many
|
-- name: GetTeamMembers :many
|
||||||
SELECT u.id, u.email, ut.role, ut.created_at AS joined_at
|
SELECT u.id, u.name, u.email, ut.role, ut.created_at AS joined_at
|
||||||
FROM users_teams ut
|
FROM users_teams ut
|
||||||
JOIN users u ON u.id = ut.user_id
|
JOIN users u ON u.id = ut.user_id
|
||||||
WHERE ut.team_id = $1
|
WHERE ut.team_id = $1
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
-- name: InsertUser :one
|
-- name: InsertUser :one
|
||||||
INSERT INTO users (id, email, password_hash)
|
INSERT INTO users (id, email, password_hash, name)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetUserByEmail :one
|
-- name: GetUserByEmail :one
|
||||||
@ -10,8 +10,8 @@ SELECT * FROM users WHERE email = $1;
|
|||||||
SELECT * FROM users WHERE id = $1;
|
SELECT * FROM users WHERE id = $1;
|
||||||
|
|
||||||
-- name: InsertUserOAuth :one
|
-- name: InsertUserOAuth :one
|
||||||
INSERT INTO users (id, email)
|
INSERT INTO users (id, email, name)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: SetUserAdmin :exec
|
-- name: SetUserAdmin :exec
|
||||||
@ -37,3 +37,6 @@ SELECT EXISTS(
|
|||||||
|
|
||||||
-- name: SearchUsersByEmailPrefix :many
|
-- name: SearchUsersByEmailPrefix :many
|
||||||
SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10;
|
SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10;
|
||||||
|
|
||||||
|
-- name: UpdateUserName :exec
|
||||||
|
UPDATE users SET name = $2, updated_at = NOW() WHERE id = $1;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ export type AuthResponse = {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
team_id: string;
|
team_id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthResult = { ok: true; data: AuthResponse } | { ok: false; error: 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 });
|
return authFetch('/api/v1/auth/login', { email, password });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiSignup(email: string, password: string): Promise<AuthResult> {
|
export async function apiSignup(email: string, password: string, name: string): Promise<AuthResult> {
|
||||||
return authFetch('/api/v1/auth/signup', { email, password });
|
return authFetch('/api/v1/auth/signup', { email, password, name });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authFetch(url: string, body: Record<string, string>): Promise<AuthResult> {
|
async function authFetch(url: string, body: Record<string, string>): Promise<AuthResult> {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { apiFetch, type ApiResult } from '$lib/api/client';
|
|||||||
|
|
||||||
export type TeamMember = {
|
export type TeamMember = {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: 'owner' | 'admin' | 'member';
|
role: 'owner' | 'admin' | 'member';
|
||||||
joined_at: string;
|
joined_at: string;
|
||||||
@ -42,7 +43,7 @@ export async function createTeam(name: string): Promise<ApiResult<TeamWithRole>>
|
|||||||
|
|
||||||
export async function switchTeam(
|
export async function switchTeam(
|
||||||
teamId: string
|
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 });
|
return apiFetch('POST', '/api/v1/auth/switch-team', { team_id: teamId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,8 @@ const STORAGE_KEYS = {
|
|||||||
token: 'wrenn_token',
|
token: 'wrenn_token',
|
||||||
userId: 'wrenn_user_id',
|
userId: 'wrenn_user_id',
|
||||||
teamId: 'wrenn_team_id',
|
teamId: 'wrenn_team_id',
|
||||||
email: 'wrenn_email'
|
email: 'wrenn_email',
|
||||||
|
name: 'wrenn_name'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function isTokenExpired(token: string): boolean {
|
function isTokenExpired(token: string): boolean {
|
||||||
@ -23,6 +24,7 @@ function createAuth() {
|
|||||||
let userId = $state<string | null>(null);
|
let userId = $state<string | null>(null);
|
||||||
let teamId = $state<string | null>(null);
|
let teamId = $state<string | null>(null);
|
||||||
let email = $state<string | null>(null);
|
let email = $state<string | null>(null);
|
||||||
|
let name = $state<string | null>(null);
|
||||||
let initialized = $state(false);
|
let initialized = $state(false);
|
||||||
|
|
||||||
// Initialize from localStorage synchronously at module load.
|
// Initialize from localStorage synchronously at module load.
|
||||||
@ -33,6 +35,7 @@ function createAuth() {
|
|||||||
userId = localStorage.getItem(STORAGE_KEYS.userId);
|
userId = localStorage.getItem(STORAGE_KEYS.userId);
|
||||||
teamId = localStorage.getItem(STORAGE_KEYS.teamId);
|
teamId = localStorage.getItem(STORAGE_KEYS.teamId);
|
||||||
email = localStorage.getItem(STORAGE_KEYS.email);
|
email = localStorage.getItem(STORAGE_KEYS.email);
|
||||||
|
name = localStorage.getItem(STORAGE_KEYS.name);
|
||||||
} else if (stored) {
|
} else if (stored) {
|
||||||
// Expired — clean up.
|
// Expired — clean up.
|
||||||
for (const key of Object.values(STORAGE_KEYS)) {
|
for (const key of Object.values(STORAGE_KEYS)) {
|
||||||
@ -57,6 +60,9 @@ function createAuth() {
|
|||||||
get email() {
|
get email() {
|
||||||
return email;
|
return email;
|
||||||
},
|
},
|
||||||
|
get name() {
|
||||||
|
return name;
|
||||||
|
},
|
||||||
get isAuthenticated() {
|
get isAuthenticated() {
|
||||||
return isAuthenticated;
|
return isAuthenticated;
|
||||||
},
|
},
|
||||||
@ -64,16 +70,18 @@ function createAuth() {
|
|||||||
return initialized;
|
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;
|
token = data.token;
|
||||||
userId = data.user_id;
|
userId = data.user_id;
|
||||||
teamId = data.team_id;
|
teamId = data.team_id;
|
||||||
email = data.email;
|
email = data.email;
|
||||||
|
name = data.name;
|
||||||
|
|
||||||
localStorage.setItem(STORAGE_KEYS.token, data.token);
|
localStorage.setItem(STORAGE_KEYS.token, data.token);
|
||||||
localStorage.setItem(STORAGE_KEYS.userId, data.user_id);
|
localStorage.setItem(STORAGE_KEYS.userId, data.user_id);
|
||||||
localStorage.setItem(STORAGE_KEYS.teamId, data.team_id);
|
localStorage.setItem(STORAGE_KEYS.teamId, data.team_id);
|
||||||
localStorage.setItem(STORAGE_KEYS.email, data.email);
|
localStorage.setItem(STORAGE_KEYS.email, data.email);
|
||||||
|
localStorage.setItem(STORAGE_KEYS.name, data.name);
|
||||||
},
|
},
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
@ -81,6 +89,7 @@ function createAuth() {
|
|||||||
userId = null;
|
userId = null;
|
||||||
teamId = null;
|
teamId = null;
|
||||||
email = null;
|
email = null;
|
||||||
|
name = null;
|
||||||
|
|
||||||
for (const key of Object.values(STORAGE_KEYS)) {
|
for (const key of Object.values(STORAGE_KEYS)) {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
let teamPopoverOpen = $state(false);
|
let teamPopoverOpen = $state(false);
|
||||||
|
|
||||||
let currentTeamName = $derived(teamsStore.list.find((t) => t.id === auth.teamId)?.name ?? '');
|
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
|
// Create team dialog
|
||||||
let showCreateTeam = $state(false);
|
let showCreateTeam = $state(false);
|
||||||
|
|||||||
@ -24,6 +24,10 @@ function createTeamsStore() {
|
|||||||
set(newTeams: TeamWithRole[]) {
|
set(newTeams: TeamWithRole[]) {
|
||||||
teams = newTeams;
|
teams = newTeams;
|
||||||
loaded = true;
|
loaded = true;
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
teams = [];
|
||||||
|
loaded = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { auth } from '$lib/auth.svelte';
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
import { teams } from '$lib/teams.svelte';
|
||||||
|
|
||||||
const params = $page.url.searchParams;
|
const params = $page.url.searchParams;
|
||||||
const error = params.get('error');
|
const error = params.get('error');
|
||||||
@ -13,9 +14,11 @@
|
|||||||
const userId = params.get('user_id');
|
const userId = params.get('user_id');
|
||||||
const teamId = params.get('team_id');
|
const teamId = params.get('team_id');
|
||||||
const email = params.get('email');
|
const email = params.get('email');
|
||||||
|
const name = params.get('name') ?? '';
|
||||||
|
|
||||||
if (token && userId && teamId && email) {
|
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');
|
goto('/dashboard');
|
||||||
} else {
|
} else {
|
||||||
goto('/login?error=missing_token');
|
goto('/login?error=missing_token');
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
|
import { auth } from '$lib/auth.svelte';
|
||||||
import {
|
import {
|
||||||
listCapsules,
|
listCapsules,
|
||||||
createCapsule,
|
createCapsule,
|
||||||
@ -440,7 +441,9 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => { showCreateDialog = true; createError = null; }}
|
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">
|
<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" />
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
@ -494,7 +497,8 @@
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick={() => { showCreateDialog = true; createError = null; }}
|
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
|
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">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
|||||||
@ -209,8 +209,8 @@
|
|||||||
);
|
);
|
||||||
toast.success(
|
toast.success(
|
||||||
newRole === 'admin'
|
newRole === 'admin'
|
||||||
? `${member.email} is now an admin`
|
? `${member.name || member.email} is now an admin`
|
||||||
: `${member.email} is now a member`
|
: `${member.name || member.email} is now a member`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
@ -675,12 +675,17 @@
|
|||||||
>
|
>
|
||||||
<!-- Table header -->
|
<!-- Table header -->
|
||||||
<div
|
<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
|
<div
|
||||||
class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
|
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>
|
||||||
<div
|
<div
|
||||||
class="px-4 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
|
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)}
|
{#each members as member, i (member.user_id)}
|
||||||
<div
|
<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 }}
|
in:fly={{ y: 6, duration: 200, delay: i * 30, easing: cubicOut }}
|
||||||
out:fly={{ x: -16, duration: 180, easing: cubicOut }}
|
out:fly={{ x: -16, duration: 180, easing: cubicOut }}
|
||||||
>
|
>
|
||||||
<!-- Email -->
|
<!-- Name -->
|
||||||
<div class="min-w-0 px-5 py-4">
|
<div class="min-w-0 px-5 py-4">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<span class="truncate text-ui text-[var(--color-text-bright)]"
|
<span class="truncate text-ui text-[var(--color-text-bright)]"
|
||||||
>{member.email}</span
|
>{member.name || member.email}</span
|
||||||
>
|
>
|
||||||
{#if member.user_id === auth.userId}
|
{#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>
|
<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>
|
||||||
</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 -->
|
<!-- Role badge -->
|
||||||
<div class="px-4 py-4">
|
<div class="px-4 py-4">
|
||||||
{#if updatingRoleId === member.user_id}
|
{#if updatingRoleId === member.user_id}
|
||||||
@ -1092,7 +1102,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
|
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
|
||||||
Remove <span class="font-medium text-[var(--color-text-secondary)]"
|
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.
|
> from the team? They will lose access immediately.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@ -3,11 +3,13 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { auth } from '$lib/auth.svelte';
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
import { teams } from '$lib/teams.svelte';
|
||||||
import { apiLogin, apiSignup } from '$lib/api/auth';
|
import { apiLogin, apiSignup } from '$lib/api/auth';
|
||||||
import {
|
import {
|
||||||
IconGithub,
|
IconGithub,
|
||||||
IconMail,
|
IconMail,
|
||||||
IconLock,
|
IconLock,
|
||||||
|
IconUser,
|
||||||
IconEye,
|
IconEye,
|
||||||
IconEyeOff
|
IconEyeOff
|
||||||
} from '$lib/components/icons';
|
} from '$lib/components/icons';
|
||||||
@ -15,6 +17,7 @@
|
|||||||
let mode: 'signin' | 'signup' = $state('signin');
|
let mode: 'signin' | 'signup' = $state('signin');
|
||||||
let email = $state('');
|
let email = $state('');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
|
let name = $state('');
|
||||||
let showPassword = $state(false);
|
let showPassword = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@ -77,6 +80,7 @@
|
|||||||
function switchMode() {
|
function switchMode() {
|
||||||
mode = mode === 'signin' ? 'signup' : 'signin';
|
mode = mode === 'signin' ? 'signup' : 'signin';
|
||||||
error = '';
|
error = '';
|
||||||
|
name = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: Event) {
|
async function handleSubmit(e: Event) {
|
||||||
@ -87,7 +91,7 @@
|
|||||||
const result =
|
const result =
|
||||||
mode === 'signin'
|
mode === 'signin'
|
||||||
? await apiLogin(email, password)
|
? await apiLogin(email, password)
|
||||||
: await apiSignup(email, password);
|
: await apiSignup(email, password, name);
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
@ -96,6 +100,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
teams.reset();
|
||||||
auth.login(result.data);
|
auth.login(result.data);
|
||||||
goto('/dashboard');
|
goto('/dashboard');
|
||||||
}
|
}
|
||||||
@ -204,6 +209,22 @@
|
|||||||
|
|
||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<form onsubmit={handleSubmit} class="space-y-3">
|
<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="group relative">
|
||||||
<div
|
<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)]"
|
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)]"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@ -15,6 +16,41 @@ import (
|
|||||||
"git.omukk.dev/wrenn/sandbox/internal/id"
|
"git.omukk.dev/wrenn/sandbox/internal/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// loginTeam returns the team and role to stamp into a login JWT.
|
||||||
|
// It prefers the user's default team; if none is flagged as default it falls
|
||||||
|
// back to the earliest-joined team. Returns pgx.ErrNoRows when the user has
|
||||||
|
// no team memberships at all.
|
||||||
|
func loginTeam(ctx context.Context, q *db.Queries, userID string) (db.Team, string, error) {
|
||||||
|
team, err := q.GetDefaultTeamForUser(ctx, userID)
|
||||||
|
if err == nil {
|
||||||
|
membership, err := q.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: userID, TeamID: team.ID})
|
||||||
|
if err != nil {
|
||||||
|
return db.Team{}, "", err
|
||||||
|
}
|
||||||
|
return team, membership.Role, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return db.Team{}, "", err
|
||||||
|
}
|
||||||
|
// No default set — fall back to earliest-joined team.
|
||||||
|
rows, err := q.GetTeamsForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return db.Team{}, "", err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return db.Team{}, "", pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
first := rows[0]
|
||||||
|
return db.Team{
|
||||||
|
ID: first.ID,
|
||||||
|
Name: first.Name,
|
||||||
|
Slug: first.Slug,
|
||||||
|
IsByoc: first.IsByoc,
|
||||||
|
CreatedAt: first.CreatedAt,
|
||||||
|
DeletedAt: first.DeletedAt,
|
||||||
|
}, first.Role, nil
|
||||||
|
}
|
||||||
|
|
||||||
type switchTeamRequest struct {
|
type switchTeamRequest struct {
|
||||||
TeamID string `json:"team_id"`
|
TeamID string `json:"team_id"`
|
||||||
}
|
}
|
||||||
@ -32,6 +68,7 @@ func newAuthHandler(db *db.Queries, pool *pgxpool.Pool, jwtSecret []byte) *authH
|
|||||||
type signupRequest struct {
|
type signupRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
@ -44,6 +81,7 @@ type authResponse struct {
|
|||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
TeamID string `json:"team_id"`
|
TeamID string `json:"team_id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signup handles POST /v1/auth/signup.
|
// Signup handles POST /v1/auth/signup.
|
||||||
@ -55,6 +93,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
if !strings.Contains(req.Email, "@") || len(req.Email) < 3 {
|
if !strings.Contains(req.Email, "@") || len(req.Email) < 3 {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid email address")
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid email address")
|
||||||
return
|
return
|
||||||
@ -63,6 +102,10 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
|
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.Name == "" || len(req.Name) > 100 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "name must be between 1 and 100 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
@ -87,6 +130,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
|||||||
ID: userID,
|
ID: userID,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
PasswordHash: pgtype.Text{String: passwordHash, Valid: true},
|
PasswordHash: pgtype.Text{String: passwordHash, Valid: true},
|
||||||
|
Name: req.Name,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var pgErr *pgconn.PgError
|
var pgErr *pgconn.PgError
|
||||||
@ -102,7 +146,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
|||||||
teamID := id.NewTeamID()
|
teamID := id.NewTeamID()
|
||||||
if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{
|
if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{
|
||||||
ID: teamID,
|
ID: teamID,
|
||||||
Name: req.Email + "'s Team",
|
Name: req.Name + "'s Team",
|
||||||
Slug: id.NewTeamSlug(),
|
Slug: id.NewTeamSlug(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to create team")
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to create team")
|
||||||
@ -124,7 +168,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email, "owner")
|
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email, req.Name, "owner")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
|
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
|
||||||
return
|
return
|
||||||
@ -135,6 +179,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
TeamID: teamID,
|
TeamID: teamID,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
|
Name: req.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,19 +218,17 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
team, err := h.db.GetDefaultTeamForUser(ctx, user.ID)
|
team, role, err := loginTeam(ctx, h.db, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusForbidden, "no_team", "user is not a member of any team")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: user.ID, TeamID: team.ID})
|
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role)
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up membership")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, membership.Role)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
|
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
|
||||||
return
|
return
|
||||||
@ -196,6 +239,7 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
TeamID: team.ID,
|
TeamID: team.ID,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
|
Name: user.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,7 +291,14 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := auth.SignJWT(h.jwtSecret, ac.UserID, req.TeamID, ac.Email, membership.Role)
|
// Fetch current name from DB — JWT name is not trusted here (may be stale or empty for old tokens).
|
||||||
|
user, err := h.db.GetUserByID(ctx, ac.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := auth.SignJWT(h.jwtSecret, ac.UserID, req.TeamID, ac.Email, user.Name, membership.Role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
|
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
|
||||||
return
|
return
|
||||||
@ -258,5 +309,6 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
|
|||||||
UserID: ac.UserID,
|
UserID: ac.UserID,
|
||||||
TeamID: req.TeamID,
|
TeamID: req.TeamID,
|
||||||
Email: ac.Email,
|
Email: ac.Email,
|
||||||
|
Name: user.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -150,25 +150,19 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
|||||||
redirectWithError(w, r, redirectBase, "db_error")
|
redirectWithError(w, r, redirectBase, "db_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
team, err := h.db.GetDefaultTeamForUser(ctx, user.ID)
|
team, role, err := loginTeam(ctx, h.db, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("oauth login: failed to get team", "error", err)
|
slog.Error("oauth login: failed to get team", "error", err)
|
||||||
redirectWithError(w, r, redirectBase, "db_error")
|
redirectWithError(w, r, redirectBase, "db_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: user.ID, TeamID: team.ID})
|
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role)
|
||||||
if err != nil {
|
|
||||||
slog.Error("oauth login: failed to get membership", "error", err)
|
|
||||||
redirectWithError(w, r, redirectBase, "db_error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, membership.Role)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("oauth login: failed to sign jwt", "error", err)
|
slog.Error("oauth login: failed to sign jwt", "error", err)
|
||||||
redirectWithError(w, r, redirectBase, "internal_error")
|
redirectWithError(w, r, redirectBase, "internal_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
redirectWithToken(w, r, redirectBase, token, user.ID, team.ID, user.Email)
|
redirectWithToken(w, r, redirectBase, token, user.ID, team.ID, user.Email, user.Name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !errors.Is(err, pgx.ErrNoRows) {
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
@ -205,6 +199,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, err = qtx.InsertUserOAuth(ctx, db.InsertUserOAuthParams{
|
_, err = qtx.InsertUserOAuth(ctx, db.InsertUserOAuthParams{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Email: email,
|
Email: email,
|
||||||
|
Name: profile.Name,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var pgErr *pgconn.PgError
|
var pgErr *pgconn.PgError
|
||||||
@ -260,14 +255,14 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, email, "owner")
|
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, email, profile.Name, "owner")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("oauth: failed to sign jwt", "error", err)
|
slog.Error("oauth: failed to sign jwt", "error", err)
|
||||||
redirectWithError(w, r, redirectBase, "internal_error")
|
redirectWithError(w, r, redirectBase, "internal_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectWithToken(w, r, redirectBase, token, userID, teamID, email)
|
redirectWithToken(w, r, redirectBase, token, userID, teamID, email, profile.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// retryAsLogin handles the race where a concurrent request already created the user.
|
// retryAsLogin handles the race where a concurrent request already created the user.
|
||||||
@ -289,33 +284,28 @@ func (h *oauthHandler) retryAsLogin(w http.ResponseWriter, r *http.Request, prov
|
|||||||
redirectWithError(w, r, redirectBase, "db_error")
|
redirectWithError(w, r, redirectBase, "db_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
team, err := h.db.GetDefaultTeamForUser(ctx, user.ID)
|
team, role, err := loginTeam(ctx, h.db, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("oauth: retry login: failed to get team", "error", err)
|
slog.Error("oauth: retry login: failed to get team", "error", err)
|
||||||
redirectWithError(w, r, redirectBase, "db_error")
|
redirectWithError(w, r, redirectBase, "db_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: user.ID, TeamID: team.ID})
|
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role)
|
||||||
if err != nil {
|
|
||||||
slog.Error("oauth: retry login: failed to get membership", "error", err)
|
|
||||||
redirectWithError(w, r, redirectBase, "db_error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, membership.Role)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("oauth: retry login: failed to sign jwt", "error", err)
|
slog.Error("oauth: retry login: failed to sign jwt", "error", err)
|
||||||
redirectWithError(w, r, redirectBase, "internal_error")
|
redirectWithError(w, r, redirectBase, "internal_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
redirectWithToken(w, r, redirectBase, token, user.ID, team.ID, user.Email)
|
redirectWithToken(w, r, redirectBase, token, user.ID, team.ID, user.Email, user.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func redirectWithToken(w http.ResponseWriter, r *http.Request, base, token, userID, teamID, email string) {
|
func redirectWithToken(w http.ResponseWriter, r *http.Request, base, token, userID, teamID, email, name string) {
|
||||||
u := base + "?" + url.Values{
|
u := base + "?" + url.Values{
|
||||||
"token": {token},
|
"token": {token},
|
||||||
"user_id": {userID},
|
"user_id": {userID},
|
||||||
"team_id": {teamID},
|
"team_id": {teamID},
|
||||||
"email": {email},
|
"email": {email},
|
||||||
|
"name": {name},
|
||||||
}.Encode()
|
}.Encode()
|
||||||
http.Redirect(w, r, u, http.StatusFound)
|
http.Redirect(w, r, u, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,6 +79,10 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ac := auth.MustFromContext(r.Context())
|
ac := auth.MustFromContext(r.Context())
|
||||||
|
if ac.TeamID == "" {
|
||||||
|
writeError(w, http.StatusForbidden, "no_team", "no active team context; re-authenticate")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sb, err := h.svc.Create(r.Context(), service.SandboxCreateParams{
|
sb, err := h.svc.Create(r.Context(), service.SandboxCreateParams{
|
||||||
TeamID: ac.TeamID,
|
TeamID: ac.TeamID,
|
||||||
|
|||||||
@ -36,6 +36,7 @@ type teamWithRoleResponse struct {
|
|||||||
|
|
||||||
type memberResponse struct {
|
type memberResponse struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
JoinedAt string `json:"joined_at,omitempty"`
|
JoinedAt string `json:"joined_at,omitempty"`
|
||||||
@ -56,6 +57,7 @@ func teamToResponse(t db.Team) teamResponse {
|
|||||||
func memberInfoToResponse(m service.MemberInfo) memberResponse {
|
func memberInfoToResponse(m service.MemberInfo) memberResponse {
|
||||||
return memberResponse{
|
return memberResponse{
|
||||||
UserID: m.UserID,
|
UserID: m.UserID,
|
||||||
|
Name: m.Name,
|
||||||
Email: m.Email,
|
Email: m.Email,
|
||||||
Role: m.Role,
|
Role: m.Role,
|
||||||
JoinedAt: m.JoinedAt.Format(time.RFC3339),
|
JoinedAt: m.JoinedAt.Format(time.RFC3339),
|
||||||
|
|||||||
@ -45,6 +45,7 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler
|
|||||||
TeamID: claims.TeamID,
|
TeamID: claims.TeamID,
|
||||||
UserID: claims.Subject,
|
UserID: claims.Subject,
|
||||||
Email: claims.Email,
|
Email: claims.Email,
|
||||||
|
Name: claims.Name,
|
||||||
Role: claims.Role,
|
Role: claims.Role,
|
||||||
})
|
})
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
|||||||
@ -29,6 +29,7 @@ func requireJWT(secret []byte) func(http.Handler) http.Handler {
|
|||||||
TeamID: claims.TeamID,
|
TeamID: claims.TeamID,
|
||||||
UserID: claims.Subject,
|
UserID: claims.Subject,
|
||||||
Email: claims.Email,
|
Email: claims.Email,
|
||||||
|
Name: claims.Name,
|
||||||
Role: claims.Role,
|
Role: claims.Role,
|
||||||
})
|
})
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
|||||||
@ -1410,7 +1410,7 @@ components:
|
|||||||
schemas:
|
schemas:
|
||||||
SignupRequest:
|
SignupRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [email, password]
|
required: [email, password, name]
|
||||||
properties:
|
properties:
|
||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
@ -1418,6 +1418,9 @@ components:
|
|||||||
password:
|
password:
|
||||||
type: string
|
type: string
|
||||||
minLength: 8
|
minLength: 8
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
maxLength: 100
|
||||||
|
|
||||||
LoginRequest:
|
LoginRequest:
|
||||||
type: object
|
type: object
|
||||||
@ -1441,6 +1444,8 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
|
||||||
CreateAPIKeyRequest:
|
CreateAPIKeyRequest:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@ -11,6 +11,7 @@ type AuthContext struct {
|
|||||||
TeamID string
|
TeamID string
|
||||||
UserID string // empty when authenticated via API key
|
UserID string // empty when authenticated via API key
|
||||||
Email string // empty when authenticated via API key
|
Email string // empty when authenticated via API key
|
||||||
|
Name string // empty when authenticated via API key
|
||||||
Role string // owner, admin, or member; empty when authenticated via API key
|
Role string // owner, admin, or member; empty when authenticated via API key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,16 +16,18 @@ type Claims struct {
|
|||||||
TeamID string `json:"team_id"`
|
TeamID string `json:"team_id"`
|
||||||
Role string `json:"role"` // owner, admin, or member within TeamID
|
Role string `json:"role"` // owner, admin, or member within TeamID
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
// SignJWT signs a new 6-hour JWT for the given user.
|
// SignJWT signs a new 6-hour JWT for the given user.
|
||||||
func SignJWT(secret []byte, userID, teamID, email, role string) (string, error) {
|
func SignJWT(secret []byte, userID, teamID, email, name, role string) (string, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
TeamID: teamID,
|
TeamID: teamID,
|
||||||
Role: role,
|
Role: role,
|
||||||
Email: email,
|
Email: email,
|
||||||
|
Name: name,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Subject: userID,
|
Subject: userID,
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
|||||||
@ -112,6 +112,7 @@ type User struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UsersTeam struct {
|
type UsersTeam struct {
|
||||||
|
|||||||
@ -26,7 +26,7 @@ func (q *Queries) DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberPara
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getBYOCTeams = `-- name: GetBYOCTeams :many
|
const getBYOCTeams = `-- name: GetBYOCTeams :many
|
||||||
SELECT id, name, created_at, is_byoc, slug, deleted_at FROM teams WHERE is_byoc = TRUE ORDER BY created_at
|
SELECT id, name, created_at, is_byoc, slug, deleted_at FROM teams WHERE is_byoc = TRUE AND deleted_at IS NULL ORDER BY created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) {
|
func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) {
|
||||||
@ -59,7 +59,7 @@ func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) {
|
|||||||
const getDefaultTeamForUser = `-- name: GetDefaultTeamForUser :one
|
const getDefaultTeamForUser = `-- name: GetDefaultTeamForUser :one
|
||||||
SELECT t.id, t.name, t.created_at, t.is_byoc, t.slug, t.deleted_at FROM teams t
|
SELECT t.id, t.name, t.created_at, t.is_byoc, t.slug, t.deleted_at FROM teams t
|
||||||
JOIN users_teams ut ON ut.team_id = t.id
|
JOIN users_teams ut ON ut.team_id = t.id
|
||||||
WHERE ut.user_id = $1 AND ut.is_default = TRUE
|
WHERE ut.user_id = $1 AND ut.is_default = TRUE AND t.deleted_at IS NULL
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@ -114,7 +114,7 @@ func (q *Queries) GetTeamBySlug(ctx context.Context, slug string) (Team, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getTeamMembers = `-- name: GetTeamMembers :many
|
const getTeamMembers = `-- name: GetTeamMembers :many
|
||||||
SELECT u.id, u.email, ut.role, ut.created_at AS joined_at
|
SELECT u.id, u.name, u.email, ut.role, ut.created_at AS joined_at
|
||||||
FROM users_teams ut
|
FROM users_teams ut
|
||||||
JOIN users u ON u.id = ut.user_id
|
JOIN users u ON u.id = ut.user_id
|
||||||
WHERE ut.team_id = $1
|
WHERE ut.team_id = $1
|
||||||
@ -123,6 +123,7 @@ ORDER BY ut.created_at
|
|||||||
|
|
||||||
type GetTeamMembersRow struct {
|
type GetTeamMembersRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
JoinedAt pgtype.Timestamptz `json:"joined_at"`
|
JoinedAt pgtype.Timestamptz `json:"joined_at"`
|
||||||
@ -139,6 +140,7 @@ func (q *Queries) GetTeamMembers(ctx context.Context, teamID string) ([]GetTeamM
|
|||||||
var i GetTeamMembersRow
|
var i GetTeamMembersRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Role,
|
&i.Role,
|
||||||
&i.JoinedAt,
|
&i.JoinedAt,
|
||||||
|
|||||||
@ -55,7 +55,7 @@ func (q *Queries) GetAdminPermissions(ctx context.Context, userID string) ([]Adm
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAdminUsers = `-- name: GetAdminUsers :many
|
const getAdminUsers = `-- name: GetAdminUsers :many
|
||||||
SELECT id, email, password_hash, created_at, updated_at, is_admin FROM users WHERE is_admin = TRUE ORDER BY created_at
|
SELECT id, email, password_hash, created_at, updated_at, is_admin, name FROM users WHERE is_admin = TRUE ORDER BY created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) {
|
func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) {
|
||||||
@ -74,6 +74,7 @@ func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) {
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.IsAdmin,
|
&i.IsAdmin,
|
||||||
|
&i.Name,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -86,7 +87,7 @@ func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||||
SELECT id, email, password_hash, created_at, updated_at, is_admin FROM users WHERE email = $1
|
SELECT id, email, password_hash, created_at, updated_at, is_admin, name FROM users WHERE email = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||||
@ -99,12 +100,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.IsAdmin,
|
&i.IsAdmin,
|
||||||
|
&i.Name,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByID = `-- name: GetUserByID :one
|
const getUserByID = `-- name: GetUserByID :one
|
||||||
SELECT id, email, password_hash, created_at, updated_at, is_admin FROM users WHERE id = $1
|
SELECT id, email, password_hash, created_at, updated_at, is_admin, name FROM users WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
|
func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
|
||||||
@ -117,6 +119,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.IsAdmin,
|
&i.IsAdmin,
|
||||||
|
&i.Name,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@ -156,19 +159,25 @@ func (q *Queries) InsertAdminPermission(ctx context.Context, arg InsertAdminPerm
|
|||||||
}
|
}
|
||||||
|
|
||||||
const insertUser = `-- name: InsertUser :one
|
const insertUser = `-- name: InsertUser :one
|
||||||
INSERT INTO users (id, email, password_hash)
|
INSERT INTO users (id, email, password_hash, name)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, email, password_hash, created_at, updated_at, is_admin
|
RETURNING id, email, password_hash, created_at, updated_at, is_admin, name
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertUserParams struct {
|
type InsertUserParams struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
PasswordHash pgtype.Text `json:"password_hash"`
|
PasswordHash pgtype.Text `json:"password_hash"`
|
||||||
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) {
|
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) {
|
||||||
row := q.db.QueryRow(ctx, insertUser, arg.ID, arg.Email, arg.PasswordHash)
|
row := q.db.QueryRow(ctx, insertUser,
|
||||||
|
arg.ID,
|
||||||
|
arg.Email,
|
||||||
|
arg.PasswordHash,
|
||||||
|
arg.Name,
|
||||||
|
)
|
||||||
var i User
|
var i User
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
@ -177,23 +186,25 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.IsAdmin,
|
&i.IsAdmin,
|
||||||
|
&i.Name,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertUserOAuth = `-- name: InsertUserOAuth :one
|
const insertUserOAuth = `-- name: InsertUserOAuth :one
|
||||||
INSERT INTO users (id, email)
|
INSERT INTO users (id, email, name)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING id, email, password_hash, created_at, updated_at, is_admin
|
RETURNING id, email, password_hash, created_at, updated_at, is_admin, name
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertUserOAuthParams struct {
|
type InsertUserOAuthParams struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams) (User, error) {
|
func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams) (User, error) {
|
||||||
row := q.db.QueryRow(ctx, insertUserOAuth, arg.ID, arg.Email)
|
row := q.db.QueryRow(ctx, insertUserOAuth, arg.ID, arg.Email, arg.Name)
|
||||||
var i User
|
var i User
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
@ -202,6 +213,7 @@ func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.IsAdmin,
|
&i.IsAdmin,
|
||||||
|
&i.Name,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@ -248,3 +260,17 @@ func (q *Queries) SetUserAdmin(ctx context.Context, arg SetUserAdminParams) erro
|
|||||||
_, err := q.db.Exec(ctx, setUserAdmin, arg.ID, arg.IsAdmin)
|
_, err := q.db.Exec(ctx, setUserAdmin, arg.ID, arg.IsAdmin)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateUserName = `-- name: UpdateUserName :exec
|
||||||
|
UPDATE users SET name = $2, updated_at = NOW() WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateUserNameParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateUserName(ctx context.Context, arg UpdateUserNameParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, updateUserName, arg.ID, arg.Name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@ -96,9 +96,10 @@ func (s *HostService) Create(ctx context.Context, p HostCreateParams) (HostCreat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate team exists for BYOC hosts.
|
// Validate team exists and is not deleted for BYOC hosts.
|
||||||
if p.TeamID != "" {
|
if p.TeamID != "" {
|
||||||
if _, err := s.DB.GetTeam(ctx, p.TeamID); err != nil {
|
team, err := s.DB.GetTeam(ctx, p.TeamID)
|
||||||
|
if err != nil || team.DeletedAt.Valid {
|
||||||
return HostCreateResult{}, fmt.Errorf("invalid request: team not found")
|
return HostCreateResult{}, fmt.Errorf("invalid request: team not found")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,9 +33,10 @@ type TeamWithRole struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MemberInfo is a team member with resolved email.
|
// MemberInfo is a team member with resolved user details.
|
||||||
type MemberInfo struct {
|
type MemberInfo struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
JoinedAt time.Time `json:"joined_at"`
|
JoinedAt time.Time `json:"joined_at"`
|
||||||
@ -215,6 +216,7 @@ func (s *TeamService) GetMembers(ctx context.Context, teamID string) ([]MemberIn
|
|||||||
}
|
}
|
||||||
members[i] = MemberInfo{
|
members[i] = MemberInfo{
|
||||||
UserID: r.ID,
|
UserID: r.ID,
|
||||||
|
Name: r.Name,
|
||||||
Email: r.Email,
|
Email: r.Email,
|
||||||
Role: r.Role,
|
Role: r.Role,
|
||||||
JoinedAt: joinedAt,
|
JoinedAt: joinedAt,
|
||||||
@ -262,7 +264,7 @@ func (s *TeamService) AddMember(ctx context.Context, teamID, callerUserID, email
|
|||||||
return MemberInfo{}, fmt.Errorf("insert member: %w", err)
|
return MemberInfo{}, fmt.Errorf("insert member: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return MemberInfo{UserID: target.ID, Email: target.Email, Role: "member"}, nil
|
return MemberInfo{UserID: target.ID, Name: target.Name, Email: target.Email, Role: "member"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveMember removes a user from the team.
|
// RemoveMember removes a user from the team.
|
||||||
|
|||||||
Reference in New Issue
Block a user