Skip to content

Commit 462928b

Browse files
committed
feat(team): invite-accept UX + sidebar team data + GitHub numeric id (Phase 3 P3.4)
Frontend for the cloud team registry: a ?invite=<token> deep link (or pasted link) raises a confirm dialog, resolves the user's GitHub identity, POSTs /team/accept, saves team config + activates team mode. The sidebar team section renders real members (avatars) + workspaces from /team/members and /team/workspaces (React Query). Worker HTTP client lives in src/lib/team-api.ts (distinct from the Tauri-invoke bridge). Adds the load-bearing GitHub numeric id to ForgeAccount (members.id = GitHub numeric id). All team UI is gated behind isTeamModeActive() so single-user mode is unchanged. Verified: typecheck, biome, vitest (1668 pass), cargo test forge (208), clippy.
1 parent f5d0bff commit 462928b

20 files changed

Lines changed: 1242 additions & 0 deletions

.changeset/team-invite-accept.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"helmor": minor
3+
---
4+
5+
Team cloud: accept an invite link to join a shared workspace, and see real team data in the sidebar.
6+
7+
- Opening Helmor with a `?invite=…` link (or pasting one into Settings → Team) registers you against the team backend with your GitHub identity and switches into team mode; unknown, expired, and already-claimed invites are surfaced distinctly.
8+
- In team mode the sidebar shows the live team roster (member avatars) and the shared sandbox's workspaces.

src-tauri/src/forge/accounts.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ pub struct ForgeAccount {
3131
pub avatar_url: Option<String>,
3232
pub email: Option<String>,
3333
pub active: bool,
34+
/// Stable numeric account id (string form), e.g. GitHub's `gh api
35+
/// /user` `.id`. A login can rename, so the numeric id is the durable
36+
/// identity used by team-mode member registration. `None` for GitLab
37+
/// (a separate id space team mode doesn't key on) or when the profile
38+
/// fetch failed.
39+
pub id: Option<String>,
3440
}
3541

3642
/// Tristate auth probe result. `LoggedOut` only when we definitively

src-tauri/src/forge/github/accounts.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ impl ForgeAccountBackend for GithubAccountBackend {
5353
avatar_url: profile.avatar_url,
5454
email: profile.email,
5555
active: false,
56+
id: profile.id.map(|id| id.to_string()),
5657
})
5758
}
5859

@@ -351,6 +352,7 @@ fn list_github_accounts_full() -> Result<Vec<ForgeAccount>> {
351352
login: slot.login.clone(),
352353
name: profile.as_ref().and_then(|p| p.name.clone()),
353354
avatar_url: profile.as_ref().and_then(|p| p.avatar_url.clone()),
355+
id: profile.as_ref().and_then(|p| p.id.map(|id| id.to_string())),
354356
email: profile.and_then(|p| p.email),
355357
active: slot.active,
356358
}
@@ -594,6 +596,10 @@ struct GhHostStatusFullEntry {
594596
#[derive(Debug, Clone, Deserialize)]
595597
#[serde(rename_all = "snake_case")]
596598
struct GithubUserResponse {
599+
/// GitHub's stable numeric account id. Surfaced as `ForgeAccount.id`
600+
/// (string form) for team-mode member identity — a login can rename,
601+
/// the id can't.
602+
id: Option<u64>,
597603
name: Option<String>,
598604
avatar_url: Option<String>,
599605
email: Option<String>,
@@ -623,6 +629,32 @@ fn looks_like_not_found(message: &str) -> bool {
623629
mod tests {
624630
use super::*;
625631

632+
#[test]
633+
fn github_user_response_captures_numeric_id() {
634+
// `gh api /user` returns a numeric `id` that survives a login
635+
// rename — the durable identity team-mode member registration
636+
// keys on. Lock the parse so the field can't silently drop.
637+
let stdout = r#"{
638+
"id": 583231,
639+
"login": "octocat",
640+
"name": "The Octocat",
641+
"avatar_url": "https://avatars.example/u/583231",
642+
"email": "octo@example.com"
643+
}"#;
644+
let parsed: GithubUserResponse = serde_json::from_str(stdout).unwrap();
645+
assert_eq!(parsed.id, Some(583231));
646+
assert_eq!(parsed.name.as_deref(), Some("The Octocat"));
647+
}
648+
649+
#[test]
650+
fn github_user_response_id_optional_when_absent() {
651+
// Defensive: a payload missing `id` must parse (id → None)
652+
// rather than erroring the whole profile fetch.
653+
let stdout = r#"{ "login": "octocat" }"#;
654+
let parsed: GithubUserResponse = serde_json::from_str(stdout).unwrap();
655+
assert_eq!(parsed.id, None);
656+
}
657+
626658
#[test]
627659
fn looks_like_not_found_matches_canonical_phrases() {
628660
assert!(looks_like_not_found("HTTP 404"));

src-tauri/src/forge/gitlab/accounts.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ impl ForgeAccountBackend for GitlabAccountBackend {
103103
avatar_url: profile.avatar_url,
104104
email: profile.email,
105105
active: true,
106+
// Team-mode member identity is GitHub-numeric-id keyed; GitLab
107+
// uses a separate id space we don't surface here.
108+
id: None,
106109
})
107110
}
108111

@@ -494,6 +497,7 @@ fn fetch_gitlab_account_with_login(host: &str, login: &str) -> ForgeAccount {
494497
avatar_url: profile.as_ref().and_then(|p| p.avatar_url.clone()),
495498
email: profile.and_then(|p| p.email),
496499
active: true,
500+
id: None,
497501
}
498502
}
499503

src/features/navigation/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
TooltipTrigger,
3434
} from "@/components/ui/tooltip";
3535
import { InlineShortcutDisplay } from "@/features/shortcuts/shortcut-display";
36+
import { TeamSection } from "@/features/team/team-section";
3637
import type {
3738
RepositoryCreateOption,
3839
StackRowMeta,
@@ -1151,6 +1152,10 @@ export const WorkspacesSidebar = memo(function WorkspacesSidebar({
11511152
<div data-tauri-drag-region className="h-full flex-1" />
11521153
</div>
11531154

1155+
{/* Team roster + shared workspaces — renders only in team mode,
1156+
so local single-user mode is unchanged. */}
1157+
<TeamSection />
1158+
11541159
<div className="mt-1 flex items-center justify-between px-3">
11551160
<h2 className="text-title font-medium text-muted-foreground">
11561161
Workspaces

src/features/settings/panels/team.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import { toast } from "sonner";
33
import { Button } from "@/components/ui/button";
44
import { Input } from "@/components/ui/input";
55
import { Switch } from "@/components/ui/switch";
6+
import { useInviteAccept } from "@/features/team/use-invite-accept";
7+
import { useTeamIdentity } from "@/features/team/use-team-identity";
68
import {
79
getTeamConfig,
810
isTeamModeActive,
11+
parseInviteLink,
912
pingTeamBackend,
1013
saveTeamConfig,
1114
setTeamModeActive,
@@ -23,7 +26,28 @@ export function TeamPanel() {
2326
const [url, setUrl] = useState(initial?.url ?? "");
2427
const [token, setToken] = useState(initial?.token ?? "");
2528
const [testing, setTesting] = useState(false);
29+
const [inviteLink, setInviteLink] = useState("");
2630
const active = isTeamModeActive();
31+
const { identity } = useTeamIdentity();
32+
const { status: acceptStatus, accept } = useInviteAccept();
33+
const joining = acceptStatus === "accepting";
34+
35+
const handleJoinWithInvite = async () => {
36+
const invite = parseInviteLink(inviteLink);
37+
if (!invite) {
38+
toast.error("That doesn't look like a valid invite link");
39+
return;
40+
}
41+
if (!identity) {
42+
toast.error("Connect a GitHub account first (Settings → Accounts)");
43+
return;
44+
}
45+
// On success the hook persists config, flips team mode on, and
46+
// reloads — so there's no follow-up here. On failure it returns the
47+
// message, which we surface as a toast.
48+
const outcome = await accept(invite, identity);
49+
if (!outcome.ok && outcome.error) toast.error(outcome.error);
50+
};
2751

2852
const handleTest = async () => {
2953
setTesting(true);
@@ -56,6 +80,32 @@ export function TeamPanel() {
5680

5781
return (
5882
<SettingsGroup>
83+
<SettingsRow
84+
title="Join with invite link"
85+
description="Paste an invite link to register with your GitHub identity and switch to the team workspace. The app reloads on success."
86+
align="start"
87+
>
88+
<div className="flex items-center gap-2">
89+
<Input
90+
value={inviteLink}
91+
onChange={(event) => setInviteLink(event.target.value)}
92+
placeholder="https://…/?invite=…"
93+
className="w-[280px]"
94+
autoComplete="off"
95+
autoCapitalize="off"
96+
spellCheck={false}
97+
disabled={joining}
98+
/>
99+
<Button
100+
size="sm"
101+
onClick={() => void handleJoinWithInvite()}
102+
disabled={joining || !inviteLink.trim()}
103+
>
104+
{joining ? "Joining…" : "Join"}
105+
</Button>
106+
</div>
107+
</SettingsRow>
108+
59109
<SettingsRow
60110
title="Team mode"
61111
description="Run against a shared cloud backend instead of this machine. The app reloads when you switch."
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { Loader2, Users } from "lucide-react";
2+
import { GithubBrandIcon } from "@/components/brand-icon";
3+
import { CachedAvatar } from "@/components/cached-avatar";
4+
import { Button } from "@/components/ui/button";
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogDescription,
9+
DialogFooter,
10+
DialogHeader,
11+
DialogTitle,
12+
} from "@/components/ui/dialog";
13+
import { initialsFor } from "@/lib/initials";
14+
import type { ParsedInvite } from "@/lib/team-mode";
15+
import { useInviteAccept } from "./use-invite-accept";
16+
import { useTeamIdentity } from "./use-team-identity";
17+
18+
/**
19+
* Headline invite-accept UX. Shown when the app is opened with a team invite
20+
* (`?invite=<token>`). Confirms the GitHub identity we'll register, then
21+
* redeems the token — on success the hook persists the config, flips team
22+
* mode on, and reloads.
23+
*/
24+
export function InviteAcceptDialog({
25+
invite,
26+
onDismiss,
27+
}: {
28+
invite: ParsedInvite;
29+
onDismiss: () => void;
30+
}) {
31+
const { identity, isLoading } = useTeamIdentity();
32+
const { status, errorMessage, accept } = useInviteAccept();
33+
const accepting = status === "accepting";
34+
const displayName = identity?.displayName?.trim() || identity?.login || "";
35+
36+
return (
37+
<Dialog
38+
open
39+
onOpenChange={(next) => {
40+
// Don't let an outside-click cancel mid-accept (the reload is
41+
// imminent); otherwise dismissing just closes the prompt.
42+
if (!next && !accepting) onDismiss();
43+
}}
44+
>
45+
<DialogContent className="max-w-[420px]">
46+
<DialogHeader>
47+
<div className="mb-1 flex size-10 items-center justify-center rounded-full bg-accent">
48+
<Users className="size-5 text-foreground" strokeWidth={2} />
49+
</div>
50+
<DialogTitle>Join the team workspace</DialogTitle>
51+
<DialogDescription>
52+
You've been invited to a shared Helmor cloud workspace at{" "}
53+
<span className="font-medium text-foreground">{invite.url}</span>.
54+
</DialogDescription>
55+
</DialogHeader>
56+
57+
{isLoading ? (
58+
<div className="flex items-center justify-center gap-2 py-6 text-small text-muted-foreground">
59+
<Loader2 className="size-3.5 animate-spin" />
60+
Detecting your GitHub account…
61+
</div>
62+
) : identity ? (
63+
<div className="flex items-center gap-3 rounded-lg border border-border/50 bg-muted/30 p-3">
64+
<div className="relative shrink-0">
65+
<CachedAvatar
66+
size="lg"
67+
className="size-9"
68+
src={identity.avatarUrl}
69+
alt={identity.login}
70+
fallback={initialsFor(displayName)}
71+
fallbackClassName="bg-muted text-ui font-semibold uppercase text-muted-foreground"
72+
/>
73+
<span className="absolute -right-1 -bottom-1 flex size-[16px] items-center justify-center rounded-full bg-background ring-2 ring-background">
74+
<GithubBrandIcon size={10} />
75+
</span>
76+
</div>
77+
<div className="min-w-0">
78+
<div className="truncate text-ui font-semibold text-foreground">
79+
{displayName}
80+
</div>
81+
<div className="truncate text-small text-muted-foreground">
82+
@{identity.login}
83+
</div>
84+
</div>
85+
</div>
86+
) : (
87+
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-small text-muted-foreground">
88+
No GitHub account is connected. Sign in to GitHub in Settings →
89+
Accounts, then reopen the invite link.
90+
</div>
91+
)}
92+
93+
{errorMessage ? (
94+
<p className="text-small text-destructive">{errorMessage}</p>
95+
) : null}
96+
97+
<DialogFooter>
98+
<Button
99+
variant="ghost"
100+
onClick={onDismiss}
101+
disabled={accepting}
102+
className="cursor-pointer"
103+
>
104+
Not now
105+
</Button>
106+
<Button
107+
onClick={() => {
108+
if (identity) void accept(invite, identity);
109+
}}
110+
disabled={!identity || accepting}
111+
className="cursor-pointer"
112+
>
113+
{accepting ? (
114+
<>
115+
<Loader2 className="size-3.5 animate-spin" />
116+
Joining…
117+
</>
118+
) : (
119+
"Join team"
120+
)}
121+
</Button>
122+
</DialogFooter>
123+
</DialogContent>
124+
</Dialog>
125+
);
126+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useState } from "react";
2+
import {
3+
clearInviteFromLocation,
4+
getInviteFromLocation,
5+
isTeamModeActive,
6+
type ParsedInvite,
7+
} from "@/lib/team-mode";
8+
import { InviteAcceptDialog } from "./invite-accept-dialog";
9+
10+
/**
11+
* Reads a team invite out of the launch URL (`?invite=<token>`) once at
12+
* mount and, when present, raises the {@link InviteAcceptDialog}. Renders
13+
* nothing in the common (no-invite) case.
14+
*
15+
* Skipped when team mode is already active — the user is already in a team,
16+
* so a leftover `?invite=` param is stale; we just strip it.
17+
*/
18+
export function InviteAcceptHost() {
19+
const [invite, setInvite] = useState<ParsedInvite | null>(() => {
20+
const detected = getInviteFromLocation();
21+
if (!detected) return null;
22+
if (isTeamModeActive()) {
23+
// Already in a team — drop the stale param, don't prompt.
24+
clearInviteFromLocation();
25+
return null;
26+
}
27+
return detected;
28+
});
29+
30+
if (!invite) return null;
31+
return (
32+
<InviteAcceptDialog
33+
invite={invite}
34+
onDismiss={() => {
35+
clearInviteFromLocation();
36+
setInvite(null);
37+
}}
38+
/>
39+
);
40+
}

0 commit comments

Comments
 (0)