1
0
forked from wrenn/wrenn

Refine team management: name chars, danger zone, no-team state

- Allow hyphens, @, and apostrophes in team names (backend regex)
- After delete/leave, switch to next available team instead of logging
  out; if no teams remain, show a toast prompting to create one
- Disable delete/leave button when user has only one team, with
  explanatory hint to create another team first
- Show empty state on /dashboard/team when auth has no team context,
  pointing user to the sidebar to create a team
- Fetch all teams in parallel with team detail on page load to power
  the isLastTeam guard
This commit is contained in:
2026-03-24 14:34:20 +06:00
parent 1e681da738
commit b3e8bdd171
2 changed files with 59 additions and 21 deletions

View File

@ -6,15 +6,18 @@
import { toast } from '$lib/toast.svelte'; import { toast } from '$lib/toast.svelte';
import { import {
getTeam, getTeam,
listTeams,
updateTeam, updateTeam,
addMember, addMember,
removeMember, removeMember,
updateMemberRole, updateMemberRole,
deleteTeam, deleteTeam,
leaveTeam, leaveTeam,
switchTeam,
searchUsers, searchUsers,
type TeamInfo, type TeamInfo,
type TeamMember, type TeamMember,
type TeamWithRole,
type UserSearchResult type UserSearchResult
} from '$lib/api/team'; } from '$lib/api/team';
@ -27,9 +30,13 @@
// Page data // Page data
let team = $state<TeamInfo | null>(null); let team = $state<TeamInfo | null>(null);
let members = $state<TeamMember[]>([]); let members = $state<TeamMember[]>([]);
let allTeams = $state<TeamWithRole[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
// True when this is the user's only team — deleting/leaving would leave them teamless
let isLastTeam = $derived(allTeams.length <= 1);
// Current user's role — derived from members list // Current user's role — derived from members list
let myRole = $derived(members.find((m) => m.user_id === auth.userId)?.role ?? 'member'); let myRole = $derived(members.find((m) => m.user_id === auth.userId)?.role ?? 'member');
let canManage = $derived(myRole === 'owner' || myRole === 'admin'); let canManage = $derived(myRole === 'owner' || myRole === 'admin');
@ -73,15 +80,24 @@
let dangerError = $state<string | null>(null); let dangerError = $state<string | null>(null);
async function fetchTeam() { async function fetchTeam() {
if (!auth.teamId) return;
loading = true; loading = true;
error = null; error = null;
const result = await getTeam(auth.teamId); if (!auth.teamId) {
if (result.ok) { loading = false;
team = result.data.team; return;
members = result.data.members; }
const [teamResult, teamsResult] = await Promise.all([
getTeam(auth.teamId),
listTeams()
]);
if (teamResult.ok) {
team = teamResult.data.team;
members = teamResult.data.members;
} else { } else {
error = result.error; error = teamResult.error;
}
if (teamsResult.ok) {
allTeams = teamsResult.data;
} }
loading = false; loading = false;
} }
@ -218,7 +234,21 @@
dangerError = null; dangerError = null;
const result = myRole === 'owner' ? await deleteTeam(team.id) : await leaveTeam(team.id); const result = myRole === 'owner' ? await deleteTeam(team.id) : await leaveTeam(team.id);
if (result.ok) { if (result.ok) {
auth.logout(); // Fetch remaining teams and switch to the first available one
const teamsResult = await listTeams();
const remaining = teamsResult.ok ? teamsResult.data : [];
if (remaining.length > 0) {
const switchResult = await switchTeam(remaining[0].id);
if (switchResult.ok) {
auth.login(switchResult.data);
window.location.reload();
return;
}
}
// No teams left — prompt user to create one
dangerLoading = false;
showDangerConfirm = false;
toast.error('No teams remaining. Use the sidebar to create a new team.');
} else { } else {
dangerError = result.error; dangerError = result.error;
dangerLoading = false; dangerLoading = false;
@ -331,6 +361,19 @@
Loading team... Loading team...
</div> </div>
</div> </div>
{:else if !auth.teamId}
<div class="flex flex-col items-center justify-center py-[72px]">
<div class="mb-5 flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<p class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">No team yet</p>
<p class="mt-1.5 max-w-xs text-center text-ui text-[var(--color-text-tertiary)]">
Use the team switcher in the sidebar to create your first team.
</p>
</div>
{:else if team} {:else if team}
<!-- ── Team Info ── --> <!-- ── Team Info ── -->
<section class="mb-8"> <section class="mb-8">
@ -765,16 +808,13 @@
Delete this team Delete this team
</p> </p>
<p class="mt-0.5 text-meta text-[var(--color-text-tertiary)]"> <p class="mt-0.5 text-meta text-[var(--color-text-tertiary)]">
Permanently deletes the team and destroys all running capsules. This cannot {#if isLastTeam}Create another team before deleting this one.{:else}Permanently deletes the team and destroys all running capsules. This cannot be undone.{/if}
be undone.
</p> </p>
</div> </div>
<button <button
onclick={() => { onclick={() => { showDangerConfirm = true; dangerError = null; }}
showDangerConfirm = true; disabled={isLastTeam}
dangerError = null; class="shrink-0 rounded-[var(--radius-button)] border px-4 py-2 text-ui font-semibold transition-all duration-150 {isLastTeam ? 'cursor-not-allowed border-[var(--color-border)] text-[var(--color-text-muted)] opacity-50' : 'border-[var(--color-red)]/40 text-[var(--color-red)] hover:bg-[var(--color-red)]/10 hover:border-[var(--color-red)]/60'}"
}}
class="shrink-0 rounded-[var(--radius-button)] border border-[var(--color-red)]/40 px-4 py-2 text-ui font-semibold text-[var(--color-red)] transition-all duration-150 hover:bg-[var(--color-red)]/10 hover:border-[var(--color-red)]/60"
> >
Delete Team Delete Team
</button> </button>
@ -784,15 +824,13 @@
Leave this team Leave this team
</p> </p>
<p class="mt-0.5 text-meta text-[var(--color-text-tertiary)]"> <p class="mt-0.5 text-meta text-[var(--color-text-tertiary)]">
You will lose access to all capsules and resources belonging to this team. {#if isLastTeam}Create another team before leaving this one.{:else}You will lose access to all capsules and resources belonging to this team.{/if}
</p> </p>
</div> </div>
<button <button
onclick={() => { onclick={() => { showDangerConfirm = true; dangerError = null; }}
showDangerConfirm = true; disabled={isLastTeam}
dangerError = null; class="shrink-0 rounded-[var(--radius-button)] border px-4 py-2 text-ui font-semibold transition-all duration-150 {isLastTeam ? 'cursor-not-allowed border-[var(--color-border)] text-[var(--color-text-muted)] opacity-50' : 'border-[var(--color-red)]/40 text-[var(--color-red)] hover:bg-[var(--color-red)]/10 hover:border-[var(--color-red)]/60'}"
}}
class="shrink-0 rounded-[var(--radius-button)] border border-[var(--color-red)]/40 px-4 py-2 text-ui font-semibold text-[var(--color-red)] transition-all duration-150 hover:bg-[var(--color-red)]/10 hover:border-[var(--color-red)]/60"
> >
Leave Team Leave Team
</button> </button>

View File

@ -18,7 +18,7 @@ import (
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
) )
var teamNameRE = regexp.MustCompile(`^[A-Za-z0-9 _]{1,128}$`) var teamNameRE = regexp.MustCompile(`^[A-Za-z0-9 _\-@']{1,128}$`)
// TeamService provides team management operations. // TeamService provides team management operations.
type TeamService struct { type TeamService struct {