Skip to content

Commit a2c274e

Browse files
Merge pull request #398 from nazarli-shabnam/feat/app-perm-consent-add-install
feat(ui): make connecting additional GitHub accounts/orgs discoverable
2 parents 0b53308 + 076dfc0 commit a2c274e

5 files changed

Lines changed: 64 additions & 10 deletions

File tree

apps/ui/app/settings/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,8 @@ function ConnectedOrgsSection() {
402402
<div className="border-t border-border p-4">
403403
{installUrl ? (
404404
<Button onClick={() => { window.location.href = installUrl }}>
405-
<ArrowSquareOut className="size-3.5" />Install GitHub App
405+
<ArrowSquareOut className="size-3.5" />
406+
{rows.length > 0 ? "Install on another account or org" : "Install GitHub App"}
406407
</Button>
407408
) : (
408409
<p className="text-xs text-muted-foreground">

apps/ui/components/app-sidebar.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ function ProfileDropdown({
6969
scopeOptions,
7070
activeScope,
7171
onSelectScope,
72-
personalInstallUrl,
72+
addInstallUrl,
7373
inviteHref,
7474
onClose,
7575
onSignOut,
@@ -78,7 +78,7 @@ function ProfileDropdown({
7878
scopeOptions: ScopeOption[]
7979
activeScope: ActiveScope | null
8080
onSelectScope: (scope: ActiveScope) => void
81-
personalInstallUrl: string | null
81+
addInstallUrl: string | null
8282
inviteHref: string
8383
onClose: () => void
8484
onSignOut: () => void
@@ -118,10 +118,10 @@ function ProfileDropdown({
118118
</div>
119119

120120
{/* Scope switcher — personal account + orgs you belong to */}
121-
{(scopeOptions.length > 0 || personalInstallUrl) && (
121+
{(scopeOptions.length > 0 || addInstallUrl) && (
122122
<div className="px-1.5 pb-1.5 border-b border-sidebar-border/60">
123123
<p className="px-2 pt-1 pb-1.5 text-[0.6875rem] font-medium uppercase tracking-wide text-sidebar-foreground/40">
124-
Switch account
124+
{scopeOptions.length > 0 ? "Switch account" : "Connect account"}
125125
</p>
126126
{scopeOptions.map((opt) => {
127127
const isActive =
@@ -140,13 +140,17 @@ function ProfileDropdown({
140140
</button>
141141
)
142142
})}
143-
{personalInstallUrl && (
143+
{addInstallUrl && (
144144
<a
145-
href={personalInstallUrl}
145+
href={addInstallUrl}
146146
className="flex items-center gap-2 px-2 py-1.5 text-left rounded-md hover:bg-sidebar-accent/60 transition-colors text-sidebar-foreground/70 hover:text-sidebar-foreground"
147147
>
148148
<User className="size-3.5 shrink-0" />
149-
<span className="text-[0.8125rem] flex-1">Connect your personal GitHub account</span>
149+
<span className="text-[0.8125rem] flex-1">
150+
{scopeOptions.some((o) => o.scope.kind === "personal")
151+
? "Add another account or org"
152+
: "Connect your personal GitHub account"}
153+
</span>
150154
<ArrowSquareOut className="size-3 shrink-0" />
151155
</a>
152156
)}
@@ -217,7 +221,9 @@ export function AppSidebar() {
217221
})
218222
const personalInstall = installs.find((i) => i.account_type === "User")
219223
const slug = process.env.NEXT_PUBLIC_GITHUB_APP_SLUG
220-
const personalInstallUrl = !personalInstall && slug ? `https://github.com/apps/${slug}/installations/new` : null
224+
// Always offer a way to install the App on another account/org (there's no cap on how
225+
// many an org admin can connect) — not only when the user has no personal install yet.
226+
const addInstallUrl = slug ? `https://github.com/apps/${slug}/installations/new` : null
221227

222228
const scopeOptions: ScopeOption[] = useMemo(
223229
() => [
@@ -344,7 +350,7 @@ export function AppSidebar() {
344350
scopeOptions={scopeOptions}
345351
activeScope={scope}
346352
onSelectScope={setScope}
347-
personalInstallUrl={personalInstallUrl}
353+
addInstallUrl={addInstallUrl}
348354
inviteHref={inviteHref}
349355
onClose={() => setOpen(false)}
350356
onSignOut={() => { logout(); setOpen(false); router.replace("/login") }}

apps/ui/tests/components/app-sidebar.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,34 @@ describe("AppSidebar scope switcher", () => {
511511
expect(connectLink).toHaveAttribute("href", "https://github.com/apps/clevis/installations/new");
512512
});
513513

514+
it("shows a 'Connect account' heading instead of 'Switch account' when there's nothing to switch between", async () => {
515+
vi.stubEnv("NEXT_PUBLIC_GITHUB_APP_SLUG", "clevis");
516+
orgMemberships = [];
517+
installations = [];
518+
renderSidebar();
519+
520+
fireEvent.click(screen.getByRole("button", { name: /user/i }));
521+
522+
await screen.findByRole("link", { name: /connect your personal github account/i });
523+
expect(screen.getByText("Connect account")).toBeInTheDocument();
524+
expect(screen.queryByText("Switch account")).not.toBeInTheDocument();
525+
});
526+
527+
it("offers 'add another account or org' even when a personal installation already exists", async () => {
528+
vi.stubEnv("NEXT_PUBLIC_GITHUB_APP_SLUG", "clevis");
529+
installations = [
530+
{ id: 1, account_login: "octocat", account_type: "User", installation_id: 5, created_at: "2026-01-01T00:00:00Z" },
531+
];
532+
renderSidebar();
533+
534+
fireEvent.click(screen.getByRole("button", { name: /user/i }));
535+
536+
const addLink = await screen.findByRole("link", { name: /add another account or org/i });
537+
expect(addLink).toHaveAttribute("href", "https://github.com/apps/clevis/installations/new");
538+
// A switchable option (the personal account) exists here, so the original heading holds.
539+
expect(screen.getByText("Switch account")).toBeInTheDocument();
540+
});
541+
514542
// Issue #371
515543
it("auto-selects the sole org membership as the active scope when nothing is persisted", async () => {
516544
orgMemberships = [{ org_login: "acme", role: "admin" }];

apps/ui/tests/components/settings-page.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,21 @@ describe("SettingsPage", () => {
339339
});
340340
});
341341

342+
it("relabels the install button once an account is already connected", async () => {
343+
vi.stubEnv("NEXT_PUBLIC_GITHUB_APP_SLUG", "clevis");
344+
orgsMineMock.mockResolvedValue([]);
345+
installationsListMock.mockResolvedValue([
346+
{ id: 1, account_login: "shabnam", account_type: "User", installation_id: 7, created_at: "2026-01-01T00:00:00Z" },
347+
]);
348+
tokensListMock.mockResolvedValue([]);
349+
configGetAllMock.mockResolvedValue({ worker_poll_seconds: "5", registration_enabled: "true" });
350+
351+
renderPage();
352+
353+
expect(await screen.findByRole("button", { name: /install on another account or org/i })).toBeInTheDocument();
354+
vi.unstubAllEnvs();
355+
});
356+
342357
it("lists both personal and admin-org installations, and disconnects one after a confirm click", async () => {
343358
orgsMineMock.mockResolvedValue([{ org_login: "acme", role: "admin" }]);
344359
installationsListMock.mockResolvedValue([

docs/self-hosting.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ This is the infrastructure/ops guide — getting the Clevis stack itself running
3838

3939
**If your GitHub App was registered before the Setup URL field above was documented here:** open the App's settings on github.com, go to the "Post installation" section, and add the Setup URL now. Orgs that already completed installation don't need to reinstall — only new installs and updates will use the new callback page going forward.
4040

41+
**Connecting more than one account/org:** there's no limit — install the App from the sidebar's "Switch account" menu or the Settings → Connected GitHub accounts button as many times as you need. Connecting an organization requires you to be a GitHub **owner** of it (Clevis live-checks this against GitHub); if you're only a member, ask an owner to connect it.
42+
43+
**After the App's permissions are widened:** when a new Clevis release adds an optional write automation (issues #286#291), existing installations keep working but the new feature stays blocked until a GitHub org owner re-approves the App's updated permission request. Clevis shows affected org admins a "N automations need extra GitHub access" notice in Settings and on the Automation page with a "Review on GitHub" link; the notice clears automatically once the owner approves (via the `installation` `new_permissions_accepted` webhook).
44+
4145
4. (Optional) Configure SMTP so self-registered accounts can verify their email: set `SMTP_HOST`, `SMTP_PORT` (default `587`), `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM` in `.env`. Without these, registration still works — accounts are created immediately — but they stay unverified and can't accept an org invitation until either SMTP is configured and the user clicks the emailed link, or they link a GitHub account instead (GitHub-verified emails are trusted immediately). Accounts created via first-run `/auth/setup` or "Sign in with GitHub" are always verified, regardless of SMTP.
4246

4347
5. (Optional) Give the worker its own Postgres credential, separate from the API's `DB_USER`/`DB_PASSWORD`: set `WORKER_DB_PASSWORD` in `.env`. This is a prerequisite for a future Row-Level Security migration (issue #190) and has no effect otherwise — the worker keeps working exactly as before if left unset. It only takes effect via the `db` container's first-ever startup (`docker-entrypoint-initdb.d` scripts only run once, against a fresh, empty data volume). If you're setting this on an **existing** deployment (a `db` volume that's already initialized), run this once by hand instead:

0 commit comments

Comments
 (0)