Skip to content

Commit 7258312

Browse files
committed
improve pre-auth dialog
Signed-off-by: Matthias Wippich <mfwippich@gmail.com>
1 parent af6415e commit 7258312

8 files changed

Lines changed: 305 additions & 24 deletions

File tree

‎runtime/src/auth/prompt.ts‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { AccessToken } from "../api/client.js";
2+
import { AuthenticationError } from "./controller.js";
3+
import type { Authentication } from "./controller.js";
4+
5+
export interface AuthenticationPromptOptions {
6+
authentication: Authentication;
7+
dialog: HTMLDialogElement;
8+
continueButton: HTMLButtonElement;
9+
closeButtons?: readonly HTMLElement[];
10+
message?: HTMLElement;
11+
}
12+
13+
export interface AuthenticationPrompt {
14+
authenticate(message?: string): Promise<AccessToken>;
15+
destroy(): void;
16+
}
17+
18+
export function createAuthenticationPrompt(options: AuthenticationPromptOptions): AuthenticationPrompt {
19+
let pending: Promise<AccessToken> | undefined;
20+
let finish: ((error?: unknown, token?: AccessToken) => void) | undefined;
21+
let started = false;
22+
let destroyed = false;
23+
24+
const cancelled = (): void => {
25+
if (!started) finish?.(new AuthenticationError("cancelled"));
26+
};
27+
const close = (): void => {
28+
cancelled();
29+
if (options.dialog.open) options.dialog.close();
30+
};
31+
const continueAuthentication = (): void => {
32+
if (!pending || started) return;
33+
started = true;
34+
// Start OAuth in the click handler so the popup retains browser user activation.
35+
let operation: Promise<AccessToken>;
36+
try {
37+
operation = options.authentication.authenticate();
38+
} catch (error) {
39+
finish?.(error);
40+
return;
41+
}
42+
if (options.dialog.open) options.dialog.close();
43+
void operation.then(
44+
(token) => { finish?.(undefined, token); },
45+
(error: unknown) => { finish?.(error); },
46+
);
47+
};
48+
options.continueButton.addEventListener("click", continueAuthentication);
49+
options.dialog.addEventListener("cancel", cancelled);
50+
options.dialog.addEventListener("close", cancelled);
51+
for (const button of options.closeButtons ?? []) button.addEventListener("click", close);
52+
53+
return {
54+
authenticate(message?: string): Promise<AccessToken> {
55+
if (destroyed) return Promise.reject(new AuthenticationError("cancelled"));
56+
if (pending) return pending;
57+
const token = options.authentication.token();
58+
if (token) return Promise.resolve(token);
59+
if (message !== undefined && options.message) options.message.textContent = message;
60+
started = false;
61+
const result = new Promise<AccessToken>((resolve, reject) => {
62+
finish = (error, authenticated) => {
63+
finish = undefined;
64+
pending = undefined;
65+
if (options.dialog.open) options.dialog.close();
66+
if (error !== undefined) reject(error instanceof Error ? error : new Error("Authentication failed"));
67+
else if (authenticated) resolve(authenticated);
68+
};
69+
});
70+
pending = result;
71+
try {
72+
options.dialog.showModal();
73+
} catch (error) {
74+
finish?.(error);
75+
}
76+
return result;
77+
},
78+
destroy(): void {
79+
if (destroyed) return;
80+
destroyed = true;
81+
options.continueButton.removeEventListener("click", continueAuthentication);
82+
options.dialog.removeEventListener("cancel", cancelled);
83+
options.dialog.removeEventListener("close", cancelled);
84+
for (const button of options.closeButtons ?? []) button.removeEventListener("click", close);
85+
finish?.(new AuthenticationError("cancelled"));
86+
},
87+
};
88+
}

‎runtime/src/auth/status.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import type { AccessToken } from "../api/client.js";
12
import type { Authentication } from "./controller.js";
23

34
export interface AuthenticationStatusOptions {
45
mount: HTMLElement;
56
authentication: Authentication;
7+
authenticate?: () => Promise<AccessToken>;
68
signedOutLabel?: string;
79
signedInLabel?: (viewerId?: string) => string;
810
explanation?: string;
@@ -69,7 +71,7 @@ export function createAuthenticationStatus(
6971
return;
7072
}
7173
action.disabled = true;
72-
void options.authentication.authenticate()
74+
void (options.authenticate ? options.authenticate() : options.authentication.authenticate())
7375
.then(() => {
7476
refresh();
7577
options.onChange?.(true);

‎runtime/src/example/page.ts‎

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ReactionState } from "../api/client.js";
33
import { trustedOrigin } from "../auth/origin.js";
44
import { Authentication } from "../auth/controller.js";
55
import { createAuthenticationStatus } from "../auth/status.js";
6+
import { createAuthenticationPrompt } from "../auth/prompt.js";
67
import {
78
setPollVote,
89
setReaction,
@@ -38,6 +39,13 @@ const otherReactions = reactionTypes.filter((reaction) =>
3839
const client = new FeedbackClient({ apiOrigin, site });
3940
const authentication = new Authentication({ site, callbackOrigin: location.origin, service: client });
4041
const status = required("status");
42+
const authenticationPrompt = createAuthenticationPrompt({
43+
authentication,
44+
dialog: required("authentication-dialog") as HTMLDialogElement,
45+
continueButton: required("authentication-continue") as HTMLButtonElement,
46+
closeButtons: [...required("authentication-dialog").querySelectorAll<HTMLElement>(".dialog-close")],
47+
message: required("authentication-message"),
48+
});
4149
let replyTo: { key: string; id: string } | undefined;
4250
let replyOrder: "oldest" | "newest" = parameters.get("replyOrder") === "newest" ? "newest" : "oldest";
4351
let authDialogEnabled = parameters.get("authDialog") !== "off";
@@ -57,6 +65,7 @@ required("api-origin").textContent = apiOrigin;
5765
const authenticationStatus = createAuthenticationStatus({
5866
mount: required("authentication-status"),
5967
authentication,
68+
authenticate: () => requireAuthentication("Sign in to continue."),
6069
explanation: "Sign in is required to add comments or replies.",
6170
onError: (error) => { status.textContent = error instanceof Error ? error.message : "Authentication failed."; },
6271
onChange: () => { updateAuthenticationUi(); void render(); },
@@ -796,27 +805,9 @@ function sortedComments(values: unknown[]): unknown[] {
796805
async function requireAuthentication(message: string): Promise<NonNullable<ReturnType<Authentication["token"]>>> {
797806
const existing = authentication.token();
798807
if (existing) return existing;
799-
if (authDialogEnabled) {
800-
const dialog = required("authentication-dialog") as HTMLDialogElement;
801-
required("authentication-message").textContent = message;
802-
await new Promise<void>((resolve, reject) => {
803-
const button = required("authentication-continue") as HTMLButtonElement;
804-
let started = false;
805-
const close = (): void => {
806-
dialog.removeEventListener("close", close);
807-
button.onclick = null;
808-
if (!started) reject(new Error("Sign in was cancelled."));
809-
};
810-
button.onclick = () => {
811-
started = true;
812-
dialog.close();
813-
resolve();
814-
};
815-
dialog.addEventListener("close", close);
816-
dialog.showModal();
817-
});
818-
}
819-
const token = await authentication.authenticate();
808+
const token = await (authDialogEnabled
809+
? authenticationPrompt.authenticate(message)
810+
: authentication.authenticate());
820811
authenticationStatus.refresh();
821812
updateAuthenticationUi();
822813
const activeReply = replyTo;

‎runtime/src/feedback/votes.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { FeedbackClient, ReactionState } from "../api/client.js";
1+
import type { AccessToken, FeedbackClient, ReactionState } from "../api/client.js";
22
import type { Authentication } from "../auth/controller.js";
33
import type { Resource } from "./resources.js";
44
import { viewerSubjectStates } from "../protocol/github.js";
@@ -12,6 +12,7 @@ export interface VoteItem {
1212
export interface VoteControlsOptions {
1313
client: FeedbackClient;
1414
authentication: Authentication;
15+
authenticate?: () => Promise<AccessToken>;
1516
items: readonly VoteItem[];
1617
format?: (count: number, selected: boolean, direction: "up" | "down") => string;
1718
onError?: (error: unknown) => void;
@@ -75,7 +76,8 @@ export function createVoteControls(options: VoteControlsOptions): VoteControls {
7576
if (currentItem.resource.key === item.resource.key) currentButton.disabled = true;
7677
}
7778
try {
78-
const token = options.authentication.token() ?? await options.authentication.authenticate();
79+
const token = options.authentication.token() ?? await (options.authenticate
80+
? options.authenticate() : options.authentication.authenticate());
7981
const resource = item.resource;
8082
let state = states.get(resource.key);
8183
if (!state?.id) {

‎runtime/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export type {
99
} from "./api/client.js";
1010
export { Authentication, AuthenticationError } from "./auth/controller.js";
1111
export type { AuthenticationOptions } from "./auth/controller.js";
12+
export { createAuthenticationPrompt } from "./auth/prompt.js";
13+
export type { AuthenticationPrompt, AuthenticationPromptOptions } from "./auth/prompt.js";
1214
export { createAuthenticationStatus } from "./auth/status.js";
1315
export type { AuthenticationStatusController, AuthenticationStatusOptions } from "./auth/status.js";
1416
export { PendingVoteStore } from "./auth/pending-vote.js";
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import type { AccessToken } from "../src/api/client.js";
4+
import { AuthenticationError } from "../src/auth/controller.js";
5+
import type { Authentication } from "../src/auth/controller.js";
6+
import { createAuthenticationPrompt } from "../src/auth/prompt.js";
7+
8+
class Dialog extends EventTarget {
9+
open = false;
10+
showModal(): void { this.open = true; }
11+
close(): void {
12+
this.open = false;
13+
this.dispatchEvent(new Event("close"));
14+
}
15+
}
16+
17+
const token = { value: "token", expiresAt: 2_000_000_000, creationGrant: "grant" } as AccessToken;
18+
19+
function fixture(existing: AccessToken | null = null) {
20+
const dialog = new Dialog();
21+
const continueButton = new EventTarget();
22+
const closeButton = new EventTarget();
23+
const message = { textContent: "" };
24+
let calls = 0;
25+
let resolveOperation!: (value: AccessToken) => void;
26+
let rejectOperation!: (reason: Error) => void;
27+
const authentication = {
28+
token: () => existing,
29+
authenticate: () => {
30+
calls++;
31+
return new Promise<AccessToken>((resolve, reject) => {
32+
resolveOperation = resolve;
33+
rejectOperation = reject;
34+
});
35+
},
36+
} as unknown as Authentication;
37+
const prompt = createAuthenticationPrompt({
38+
authentication,
39+
dialog: dialog as unknown as HTMLDialogElement,
40+
continueButton: continueButton as unknown as HTMLButtonElement,
41+
closeButtons: [closeButton as unknown as HTMLElement],
42+
message: message as unknown as HTMLElement,
43+
});
44+
return { dialog, continueButton, closeButton, message, prompt,
45+
calls: () => calls, resolve: (value = token) => { resolveOperation(value); },
46+
reject: (error: Error) => { rejectOperation(error); } };
47+
}
48+
49+
void test("existing session bypasses the prompt", async () => {
50+
const f = fixture(token);
51+
assert.equal(await f.prompt.authenticate("Hello"), token);
52+
assert.equal(f.dialog.open, false);
53+
assert.equal(f.calls(), 0);
54+
f.prompt.destroy();
55+
});
56+
57+
void test("Continue starts one OAuth operation for simultaneous callers", async () => {
58+
const f = fixture();
59+
const first = f.prompt.authenticate("Sign in to vote");
60+
const second = f.prompt.authenticate("Other message");
61+
assert.equal(first, second);
62+
assert.equal(f.message.textContent, "Sign in to vote");
63+
f.continueButton.dispatchEvent(new Event("click"));
64+
assert.equal(f.calls(), 1);
65+
assert.equal(f.dialog.open, false);
66+
f.resolve();
67+
assert.equal(await first, token);
68+
f.prompt.destroy();
69+
});
70+
71+
void test("close button, native cancel, and external close cancel the prompt", async () => {
72+
for (const method of ["button", "cancel", "close"] as const) {
73+
const f = fixture();
74+
const pending = f.prompt.authenticate();
75+
if (method === "button") f.closeButton.dispatchEvent(new Event("click"));
76+
if (method === "cancel") f.dialog.dispatchEvent(new Event("cancel"));
77+
if (method === "close") f.dialog.close();
78+
await assert.rejects(pending, (error: unknown) =>
79+
error instanceof AuthenticationError && error.code === "cancelled");
80+
f.prompt.destroy();
81+
}
82+
});
83+
84+
void test("destroy settles pending work and removes listeners", async () => {
85+
const f = fixture();
86+
const pending = f.prompt.authenticate();
87+
f.prompt.destroy();
88+
await assert.rejects(pending, (error: unknown) =>
89+
error instanceof AuthenticationError && error.code === "cancelled");
90+
f.continueButton.dispatchEvent(new Event("click"));
91+
assert.equal(f.calls(), 0);
92+
});
93+
94+
void test("failed OAuth closes the prompt and permits retry", async () => {
95+
const f = fixture();
96+
const first = f.prompt.authenticate();
97+
f.continueButton.dispatchEvent(new Event("click"));
98+
f.reject(new Error("OAuth failed"));
99+
await assert.rejects(first, /OAuth failed/);
100+
const second = f.prompt.authenticate();
101+
assert.equal(f.dialog.open, true);
102+
f.continueButton.dispatchEvent(new Event("click"));
103+
f.resolve();
104+
assert.equal(await second, token);
105+
assert.equal(f.calls(), 2);
106+
f.prompt.destroy();
107+
});

‎runtime/test/authentication.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import test from "node:test";
44
import { Authentication, type AuthEnvironment, type AuthMessage } from "../src/auth/controller.js";
55
import type { OAuthTransport } from "../src/auth/controller.js";
66
import type { KeyValueStorage } from "../src/storage.js";
7+
import { createAuthenticationStatus } from "../src/auth/status.js";
78

89
void test("authentication verifies the callback and GitHub viewer before storing a token", async () => {
910
const values = new Map<string, string>();
@@ -124,3 +125,46 @@ void test("authentication permits loopback HTTP but rejects other insecure origi
124125
storage,
125126
}), /loopback/);
126127
});
128+
129+
void test("authentication status uses the supplied login callback", async () => {
130+
const previousDocument = globalThis.document;
131+
class Element extends EventTarget {
132+
className = "";
133+
type = "";
134+
textContent = "";
135+
disabled = false;
136+
onclick: (() => void) | null = null;
137+
classList = { toggle: () => undefined };
138+
append(): void { /* children are not needed here */ }
139+
replaceChildren(): void { /* children are not needed here */ }
140+
setAttribute(): void { /* attributes are not needed here */ }
141+
}
142+
const elements: Element[] = [];
143+
globalThis.document = {
144+
createElement: () => { const element = new Element(); elements.push(element); return element; },
145+
createTextNode: (value: string) => ({ textContent: value }),
146+
} as unknown as Document;
147+
try {
148+
const token = { value: "token", expiresAt: 2_000_000_000, creationGrant: "grant" };
149+
let current: typeof token | null = null;
150+
let directCalls = 0;
151+
let customCalls = 0;
152+
const authentication = {
153+
token: () => current,
154+
authenticate: () => { directCalls++; return Promise.resolve(token); },
155+
clear: () => { current = null; },
156+
} as unknown as Authentication;
157+
const mount = { replaceChildren: () => undefined } as unknown as HTMLElement;
158+
createAuthenticationStatus({ mount, authentication,
159+
authenticate: () => { customCalls++; current = token; return Promise.resolve(token); },
160+
});
161+
const action = elements[1];
162+
assert.ok(action);
163+
action.onclick?.();
164+
await new Promise((resolve) => setImmediate(resolve));
165+
assert.equal(customCalls, 1);
166+
assert.equal(directCalls, 0);
167+
} finally {
168+
globalThis.document = previousDocument;
169+
}
170+
});

0 commit comments

Comments
 (0)