Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Metadata } from "next";

import { SignInAddSkeleton, SignInAgainPage } from "../../../../../../_components/sign-in-add-page";
import { serverReadQuery } from "../../../../../../../lib/server-client";
import { ProductPage } from "../../../../../_lib/workspace-page";

export const metadata: Metadata = { title: "Sign in again" };
export default function Page({ params }: { params: Promise<{ team: string; project: string; signIn: string }> }) {
return (
<ProductPage
params={params}
fallback={<SignInAddSkeleton title="Sign in again" />}
prefetch={(client, { projectId }) => client.prefetchQuery(serverReadQuery("projectSignIns", projectId))}
>
{({ signIn }) => <SignInAgainPage signInId={signIn} />}
</ProductPage>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default function Page({ params }: { params: Promise<{ team: string; proje
return (
<ProductPage
params={params}
fallback={<SignInAddSkeleton />}
fallback={<SignInAddSkeleton title="Add a way in" />}
prefetch={(client, { projectId }) => client.prefetchQuery(serverReadQuery("projectSignIns", projectId))}
>
<SignInAddPage />
Expand Down
66 changes: 55 additions & 11 deletions apps/console/app/_components/sign-in-add-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, TriangleAlert } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";

import type { BoundSignIn } from "../../lib/web-client";
import { readQuery } from "../../lib/web-client";
import { PageColumn, PageTitle, ProductGate, QueryError, useProduct } from "./shell";
import { PageSkeleton } from "./page-skeleton";
import { PASSWORD_METHOD } from "./sign-in-methods";
import { GitHubAuthenticatorAction, GitHubAuthenticatorEditor } from "./github-authenticator";
import { SignInStageBody, useSignInStage } from "./sign-in-stage";
import type { Binding } from "./use-sign-in-binding";

/**
* Adding a way in from a product's settings, as an address of its own.
* Adding a way in from a product's settings, or signing in again to one the
* product already uses, as an address of its own.
*
* A route of its own rather than a modal: settings is a narrow page and the
* browser wants the width, the job already has a go in, finish,
Expand Down Expand Up @@ -56,20 +58,39 @@ function GitHubAuthenticatorOffer({ projectId, binding }: { projectId: string; b
);
}

function AddStage({ bound }: { bound: readonly BoundSignIn[] }) {
/**
* Signing in again opens the way in's browser on arrival: the press that led
* here was the ask, and the page has nothing else to offer before it.
*/
function useOpenOnArrival(pick: (method: BoundSignIn["method"], signInId: string) => void, again: BoundSignIn | null) {
const asked = useRef(false);
useEffect(() => {
if (!again || asked.current) return;
asked.current = true;
pick(again.method, again.id);
}, [again, pick]);
}

function AddStage({ bound, again, title }: {
bound: readonly BoundSignIn[];
/** The saved way in being signed in to again; null adds a new one. */
again: BoundSignIn | null;
title: string;
}) {
const router = useRouter();
const { project, teamHref } = useProduct();
const settings = teamHref("/settings/sign-ins");
const stage = useSignInStage(project, bound);
const { binding, busy } = stage;
useOpenOnArrival(stage.bind.actions.pick, again);
return (
<div className="page signin-add-page">
<PageTitle
actions={<Link className="button" href={settings}><ChevronLeft aria-hidden="true" />Back to settings</Link>}
inlineActions
title="Add a way in"
title={title}
/>
<section className="workbench signin-add-stage" aria-label="Add a way in">
<section className="workbench signin-add-stage" aria-label={title}>
{/* This route is always bind mode, so the stage carries its own at-rest
placeholder: the question before a pick, and what just happened once
a bind has ticked and handed the browser back. Same reading as the
Expand All @@ -93,22 +114,45 @@ function AddStage({ bound }: { bound: readonly BoundSignIn[] }) {
);
}

function AddContent() {
/** A way in this product does not use, or one ShipBash signs in with itself, has no browser to give back. */
function NotSignedInAgain({ title }: { title: string }) {
const { teamHref } = useProduct();
return (
<div className="page signin-add-page">
<PageTitle title={title} />
<section className="settings-card">
<p>This product does not sign in with that way in.</p>
<Link className="button" href={teamHref("/settings/sign-ins")}><ChevronLeft aria-hidden="true" />Back to settings</Link>
</section>
</div>
);
}

function AddContent({ againId }: { againId: string | null }) {
const { project } = useProduct();
const signIns = useQuery(readQuery("projectSignIns", project.id));
const title = againId ? "Sign in again" : "Add a way in";

if (signIns.isPending) {
return <SignInAddSkeleton />;
return <SignInAddSkeleton title={title} />;
}
if (signIns.isLoadingError) return <QueryError error={signIns.error} retry={() => void signIns.refetch()} />;
return <AddStage bound={signIns.data.signIns} />;
if (!againId) return <AddStage bound={signIns.data.signIns} again={null} title={title} />;
const again = signIns.data.signIns.find((signIn) => signIn.id === againId);
if (!again || again.method === PASSWORD_METHOD) return <NotSignedInAgain title={title} />;
return <AddStage bound={signIns.data.signIns} again={again} title={title} />;
}

/** Saving a way in, before the product's sign-ins answer. */
export function SignInAddSkeleton() {
return <PageSkeleton className="page signin-add-page" title="Add a way in" />;
export function SignInAddSkeleton({ title }: { title: string }) {
return <PageSkeleton className="page signin-add-page" title={title} />;
}

export function SignInAddPage() {
return <ProductGate><PageColumn><AddContent /></PageColumn></ProductGate>;
return <ProductGate><PageColumn><AddContent againId={null} /></PageColumn></ProductGate>;
}

/** Signing in again to a saved way in: the same page, opened on that way in's browser. */
export function SignInAgainPage({ signInId }: { signInId: string }) {
return <ProductGate><PageColumn><AddContent againId={signInId} /></PageColumn></ProductGate>;
}
4 changes: 3 additions & 1 deletion apps/console/app/_components/sign-in-setup-head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ export function railHead(binding: Binding | null, saved: readonly TeamSignIn[],
const name = METHOD_NAMES[binding.method];
if (binding.phase === "verifying") return { title: `Checking the ${name} sign-in`, lede: VERIFY_LEDE };
if (binding.method === PASSWORD_METHOD) return { title: name, lede: PASSWORD_LEDE };
const lede = binding.touched ? FINISH_LEDE : WATCHING_LEDE;
if (binding.signInId) return { title: `Signing in again · ${name}`, lede };
const article = VOWEL_SOUND_METHODS.has(binding.method) ? "an" : "a";
return { title: `Adding ${article} ${name} sign-in`, lede: binding.touched ? FINISH_LEDE : WATCHING_LEDE };
return { title: `Adding ${article} ${name} sign-in`, lede };
}
if (saved.length === 0) return { title: `How does ShipBash get into ${host}?`, lede: PICK_LEDE };
return { title: savedTitle(saved.length), lede: savedLede(saved) };
Expand Down
42 changes: 30 additions & 12 deletions apps/console/app/_components/team-sign-ins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { useWorkspace } from "./shell";
import { teamProjects } from "./workspace-address";
import { projectMark } from "./workspace-marks";
import type { Workspace } from "./workspace-session";
import { METHOD_NAMES, SignInMark } from "./sign-in-methods";
import { METHOD_NAMES, PASSWORD_METHOD, SignInMark } from "./sign-in-methods";
import { PausedHint, signInState } from "./sign-in-status";
import { Dropdown } from "./dropdown";
import { GitHubAuthenticatorEditor, GitHubAuthenticatorFact } from "./github-authenticator";
Expand Down Expand Up @@ -54,6 +54,22 @@ function takeoverHref(
return target ? teamHref("/settings/sign-ins/new", { project: target.slug }) : null;
}

/**
* Where "Sign in again" goes, or null when it is not offered. A way in that
* keeps a session a person made holds it only until the provider ends it,
* which no status on this row can see coming, so the browser is always offered
* back — on the Project in scope, or one that uses this way in. The password
* lane signs in by itself, which is "Check again".
*/
function signInAgainHref(
signIn: TeamSignIn,
home: Project | null,
teamHref: Workspace["teamHref"],
): Route | null {
if (signIn.method === PASSWORD_METHOD || !home) return null;
return teamHref(`/settings/sign-ins/${encodeURIComponent(signIn.id)}/again`, { project: home.slug });
}

/**
* Where "Check again" would run, or null when it cannot run anywhere. A sign-in
* belongs to the Team, but proving one means opening a product with it, so the
Expand All @@ -62,14 +78,10 @@ function takeoverHref(
* nothing left to open, and the check would fail for a reason the reader cannot
* act on.
*/
function verifyTarget(
signIn: TeamSignIn,
projects: readonly Project[],
scoped: Project | null,
): Project | null {
function verifyTarget(signIn: TeamSignIn, home: Project | null): Project | null {
if (!signIn.keyAvailable) return null;
if (signIn.status !== "paused" && signIn.status !== "failed") return null;
return projects.find((project) => project.id === scoped?.id) ?? projects[0] ?? null;
return home;
}

/**
Expand Down Expand Up @@ -121,7 +133,7 @@ function RunsMark({ projects }: { projects: readonly Project[] }) {
);
}

function SignInRow({ signIn, projects, runsOn, admin, onRemove, onVerify, takeover, authenticatorProject, onAuthenticator, onUnbind }: {
function SignInRow({ signIn, projects, runsOn, admin, onRemove, onVerify, again, takeover, authenticatorProject, onAuthenticator, onUnbind }: {
signIn: TeamSignIn;
projects: readonly Project[];
/** The products whose verification runs sign in with this way in. */
Expand All @@ -130,6 +142,8 @@ function SignInRow({ signIn, projects, runsOn, admin, onRemove, onVerify, takeov
onRemove: () => void;
/** Hand the saved session to a leg once more, when a product can receive it. */
onVerify: (() => void) | null;
/** The browser back, to sign in to this way in again. */
again: Route | null;
/** The way back in that does not need a password, when this row is paused. */
takeover: Route | null;
authenticatorProject: Project | null;
Expand All @@ -154,6 +168,7 @@ function SignInRow({ signIn, projects, runsOn, admin, onRemove, onVerify, takeov
{signIn.status === "ready" ? null : <span className={`status-pill${state.tone}`}>{state.text}</span>}
<RunsMark projects={runsOn} />
<SignInWhere projects={projects} />
{admin && again ? <Link className="button" href={again}>Sign in again</Link> : null}
{admin && onVerify ? (
<button className="button" type="button" onClick={onVerify}>Check again</button>
) : null}
Expand Down Expand Up @@ -203,22 +218,25 @@ function TeamSignInRows({ signIns, usage, runs, scoped, admin, teamHref, onRemov
<ul className="settings-list signin-list">
{signIns.map((signIn) => {
const used = usage.get(signIn.id) ?? [];
const target = verifyTarget(signIn, used, scoped);
const authenticatorProject = used.find((entry) => entry.id === scoped?.id) ?? used[0] ?? null;
// A row's actions open a product: the one in scope when it uses this
// way in, otherwise the first that does.
const home = used.find((entry) => entry.id === scoped?.id) ?? used[0] ?? null;
const target = verifyTarget(signIn, home);
return (
<SignInRow
key={signIn.id}
admin={admin}
onRemove={() => onRemove(signIn.id)}
onVerify={target ? () => onVerify(target.id, signIn.id) : null}
authenticatorProject={authenticatorProject}
authenticatorProject={home}
onAuthenticator={() => {
if (authenticatorProject) onAuthenticator(authenticatorProject.id, signIn.id);
if (home) onAuthenticator(home.id, signIn.id);
}}
onUnbind={(projectId) => onUnbind(projectId, signIn.id)}
projects={used}
runsOn={runs.get(signIn.id) ?? []}
signIn={signIn}
again={signInAgainHref(signIn, home, teamHref)}
takeover={takeoverHref(signIn, used, scoped, teamHref)}
/>
);
Expand Down
20 changes: 13 additions & 7 deletions apps/console/app/_components/use-sign-in-binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,15 @@ interface SaveAttempt {
signInId: string | null;
}

function opened(method: TeamSignInMethod, draft: PasswordDraft | null): Binding {
/**
* A bind just begun. Signing in again to a saved way in starts on its row,
* which is where the session the person makes is kept.
*/
function opened(method: TeamSignInMethod, draft: PasswordDraft | null, signInId: string | null): Binding {
return {
method,
phase: draft ? "form" : "opening",
signInId: null,
signInId,
failureReason: null,
touched: false,
confirming: false,
Expand Down Expand Up @@ -139,8 +143,8 @@ function useBindingWrites(
});

const finish = useMutation({
mutationFn: async (method: SessionSignInMethod) => {
const { signIn } = await webClient.completeSignInTakeover(projectId, method);
mutationFn: async ({ method, signInId }: { method: SessionSignInMethod; signInId: string | null }) => {
const { signIn } = await webClient.completeSignInTakeover(projectId, method, signInId);
return signIn;
},
onSuccess: async (signIn) => {
Expand Down Expand Up @@ -254,9 +258,9 @@ function useBindingSelection(options: {
setTakeoverPhase: Dispatch<SetStateAction<TakeoverPhase>>;
}) {
const { loginUrl, takeover, release, setBinding, dropPicture, setTakeoverPhase } = options;
const pick = useCallback((method: TeamSignInMethod) => {
const pick = useCallback((method: TeamSignInMethod, signInId: string | null = null) => {
const draft = method === PASSWORD_METHOD ? emptyPasswordDraft(loginUrl) : null;
setBinding(opened(method, draft));
setBinding(opened(method, draft, signInId));
dropPicture();
setTakeoverPhase(method === PASSWORD_METHOD ? "agent" : "claiming");
takeover.mutate(method);
Expand Down Expand Up @@ -326,7 +330,9 @@ export function useSignInBinding(project: Project) {

const presentation = useSignInPresentation({
binding, phase: takeoverPhase, setPhase: setTakeoverPhase,
submit: (method, accepted) => finish.mutate(method, { onSuccess: accepted }),
// The row the bind is on, when it has one: a saved way in signed in to
// again, or the row an earlier try of this bind saved.
submit: (method, accepted) => finish.mutate({ method, signInId: binding?.signInId ?? null }, { onSuccess: accepted }),
});
const { drop: dropPicture } = presentation;
const { pick, discard, cancel } = useBindingSelection({
Expand Down
5 changes: 3 additions & 2 deletions apps/console/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,9 @@ h1 { margin-bottom: 7px; font-size: clamp(25px, 3vw, var(--text-3xl)); line-heig
.rp-poster { position: absolute; inset: 0; z-index: 6; display: grid; grid-template-columns: minmax(0, 1fr); align-content: end; overflow: hidden; pointer-events: none; background: linear-gradient(to top, rgba(10,10,12,.93) 0%, rgba(10,10,12,.9) 22%, rgba(10,10,12,.84) 36%, rgba(10,10,12,.74) 48%, rgba(10,10,12,.6) 60%, rgba(10,10,12,.42) 72%, rgba(10,10,12,.24) 84%, rgba(10,10,12,.1) 93%, rgba(10,10,12,0) 100%); }
.rp-poster:not([hidden]) + .qrp-caption, .qrp-caption:has(+ .rp-poster) { display: none; }
/* With a cover on it, the stage is not empty: saying so under the scrim reads
as two answers to one question. */
.case-evidence-stage:has(.rp-poster) .case-evidence-empty { display: none; }
as two answers to one question. Only the words go — the empty frame is the
one box the absolutely placed cover has to sit on. */
.case-evidence-stage:has(.rp-poster) .case-evidence-empty strong { visibility: hidden; }
.rp-poster-ink { display: grid; gap: 14px; padding: clamp(26px, 5vw, 52px) clamp(16px, 2.6vw, 28px) clamp(16px, 2.6vw, 28px); }
.rp-poster button, .rp-poster a, .rp-poster-desc { pointer-events: auto; }
/* The run's own words are the page's ONLY copy of them, so they are never
Expand Down
14 changes: 8 additions & 6 deletions apps/console/lib/production-sign-in-takeover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
createSignInRecheck,
type SignInVerificationContext,
type SignInVerificationStore,
takeoverRow,
} from "@shipbash/control-plane/service/sign-in-verification";
import type { SignInTakeover, SignInTakeoverAccess } from "@shipbash/control-plane/store/sign-in-takeovers";
import type { TargetAuthStateAccess } from "@shipbash/control-plane/store/target-auth-state";
Expand Down Expand Up @@ -174,17 +175,18 @@ async function release(resolved: Resolved, projectId: string): Promise<void> {

/**
* "I'm done": the way in saved for the Team as ready and bound to this
* Project, and the session the person made sealed on the Project's profile
* Project, on a new row or on the saved one the person signed in to again, and
* the session the person made sealed on the Project's profile
* when runs sign in with this way in — the first one the Project saves, or the
* one they already use, whose runs parked on a sign-in then resume. The
* browser has done its job whichever way the save went, so the Sandbox goes.
*/
async function complete(
resolved: Resolved,
input: Scope & { method: Parameters<SignInVerificationGateway["complete"]>[0]["method"] },
input: Parameters<SignInVerificationGateway["complete"]>[0],
): Promise<TeamSignIn> {
const { dependencies, factory } = resolved;
const { actor, teamId, projectId, method } = input;
const { actor, teamId, projectId } = input;
const productionUrl = (await dependencies.store.getProject(actor, teamId, projectId))?.productionUrl;
if (!productionUrl) {
throw new HttpError(409, "PRODUCTION_URL_REQUIRED", "This Project has no Production URL, so there is nowhere to keep the sign-in for.");
Expand All @@ -203,12 +205,12 @@ async function complete(
} finally {
await endTakeover(resolved, existing);
}
const created = await dependencies.store.createTeamSignIn(actor, teamId, { method });
const target = await takeoverRow(dependencies.store, input);
// A ready way in carries the session it was ticked on (team_sign_ins_ready_check).
// The Sandbox's key died with it, so the row keeps the state itself — sealed
// at rest by the Team keyring like everything on it — named by its own bytes,
// with the GitHub account its cookies name.
const signIn = await dependencies.store.updateTeamSignIn(actor, teamId, created.id, {
const signIn = await dependencies.store.updateTeamSignIn(actor, teamId, target.id, {
status: "ready",
failureReason: null,
consecutiveFailures: 0,
Expand All @@ -217,7 +219,7 @@ async function complete(
version: authStateVersion(session),
githubAccountLogin,
},
}) ?? created;
}) ?? target;
await dependencies.store.bindProjectSignIn(actor, teamId, projectId, signIn.id);
await dependencies.store.recordTeamSignInEvent(actor, teamId, { signInId: signIn.id, projectId, actor: "user", action: "saved" });
if (await dependencies.store.claimProjectRunSignIn(actor, teamId, projectId, signIn.id)) {
Expand Down
Loading
Loading