fix(den): refresh desktop sign-in state and support local handoff (#1389)

* fix(den): make desktop signin state update immediately

* fix(den): support local auth verification and handoff

---------

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
Source Open
2026-04-07 16:28:52 -07:00
committed by GitHub
parent 60c7e87eaa
commit 7e82cb7253
6 changed files with 283 additions and 144 deletions

View File

@@ -16,6 +16,11 @@ import {
resolveDenBaseUrls,
writeDenSettings,
} from "../lib/den";
import {
denSessionUpdatedEvent,
dispatchDenSessionUpdated,
type DenSessionUpdatedDetail,
} from "../lib/den-session-events";
import {
clearDenTemplateCache,
loadDenTemplateCache,
@@ -78,9 +83,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
const platform = usePlatform();
const tr = (key: string) => t(key, currentLocale());
const initial = readDenSettings();
const initialBaseUrl = props.developerMode
? initial.baseUrl || DEFAULT_DEN_BASE_URL
: DEFAULT_DEN_BASE_URL;
const initialBaseUrl = initial.baseUrl || DEFAULT_DEN_BASE_URL;
const [baseUrl, setBaseUrl] = createSignal(initialBaseUrl);
const [baseUrlDraft, setBaseUrlDraft] = createSignal(initialBaseUrl);
@@ -155,7 +158,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
createEffect(() => {
writeDenSettings({
baseUrl: props.developerMode ? baseUrl() : DEFAULT_DEN_BASE_URL,
baseUrl: baseUrl(),
authToken: authToken() || null,
activeOrgId: activeOrgId() || null,
activeOrgSlug: activeOrg()?.slug ?? null,
@@ -163,14 +166,6 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
});
});
createEffect(() => {
if (!props.developerMode) {
setBaseUrl(DEFAULT_DEN_BASE_URL);
setBaseUrlDraft(DEFAULT_DEN_BASE_URL);
setBaseUrlError(null);
}
});
const openControlPlane = () => {
platform.openLink(resolveDenBaseUrls(baseUrl()).baseUrl);
};
@@ -239,7 +234,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
}
writeDenSettings({
baseUrl: props.developerMode ? nextBaseUrl : DEFAULT_DEN_BASE_URL,
baseUrl: nextBaseUrl,
authToken: result.token,
activeOrgId: null,
activeOrgSlug: null,
@@ -248,26 +243,21 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
setManualAuthInput("");
setManualAuthOpen(false);
window.dispatchEvent(
new CustomEvent("openwork-den-session-updated", {
detail: {
status: "success",
email: result.user?.email ?? null,
},
}),
);
dispatchDenSessionUpdated({
status: "success",
baseUrl: nextBaseUrl,
token: result.token,
user: result.user,
email: result.user?.email ?? null,
});
} catch (error) {
window.dispatchEvent(
new CustomEvent("openwork-den-session-updated", {
detail: {
status: "error",
message:
error instanceof Error
? error.message
: tr("den.error_signin_failed"),
},
}),
);
dispatchDenSessionUpdated({
status: "error",
message:
error instanceof Error
? error.message
: tr("den.error_signin_failed"),
});
} finally {
setAuthBusy(false);
}
@@ -337,7 +327,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
const nextOrg = response.orgs.find((org) => org.id === next) ?? null;
setActiveOrgId(next);
writeDenSettings({
baseUrl: props.developerMode ? baseUrl() : DEFAULT_DEN_BASE_URL,
baseUrl: baseUrl(),
authToken: authToken() || null,
activeOrgId: next || null,
activeOrgSlug: nextOrg?.slug ?? null,
@@ -472,18 +462,27 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
createEffect(() => {
const handler = (event: Event) => {
const customEvent = event as CustomEvent<{
status?: string;
email?: string | null;
message?: string | null;
}>;
const customEvent = event as CustomEvent<DenSessionUpdatedDetail>;
const nextSettings = readDenSettings();
setBaseUrl(nextSettings.baseUrl || DEFAULT_DEN_BASE_URL);
setBaseUrlDraft(nextSettings.baseUrl || DEFAULT_DEN_BASE_URL);
setAuthToken(nextSettings.authToken?.trim() || "");
const nextBaseUrl =
customEvent.detail?.baseUrl?.trim() ||
nextSettings.baseUrl ||
DEFAULT_DEN_BASE_URL;
const nextToken =
customEvent.detail?.token?.trim() ||
nextSettings.authToken?.trim() ||
"";
setBaseUrl(nextBaseUrl);
setBaseUrlDraft(nextBaseUrl);
setAuthToken(nextToken);
setActiveOrgId(nextSettings.activeOrgId?.trim() || "");
if (customEvent.detail?.status === "success") {
clearSessionState();
if (customEvent.detail.user) {
setUser(customEvent.detail.user);
}
setAuthError(null);
setSessionBusy(false);
setStatusMessage(
customEvent.detail.email?.trim()
? t("den.status_cloud_signed_in_as", currentLocale(), { email: customEvent.detail.email.trim() })
@@ -498,12 +497,12 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
};
window.addEventListener(
"openwork-den-session-updated",
denSessionUpdatedEvent,
handler as EventListener,
);
return () =>
window.removeEventListener(
"openwork-den-session-updated",
denSessionUpdatedEvent,
handler as EventListener,
);
});
@@ -828,7 +827,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
const nextOrg = orgs().find((org) => org.id === nextId) ?? null;
setActiveOrgId(nextId);
writeDenSettings({
baseUrl: props.developerMode ? baseUrl() : DEFAULT_DEN_BASE_URL,
baseUrl: baseUrl(),
authToken: authToken() || null,
activeOrgId: nextId || null,
activeOrgSlug: nextOrg?.slug ?? null,

View File

@@ -0,0 +1,24 @@
import type { DenUser } from "./den";
export const denSessionUpdatedEvent = "openwork-den-session-updated";
export type DenSessionUpdatedDetail = {
status?: "success" | "error";
baseUrl?: string | null;
token?: string | null;
user?: DenUser | null;
email?: string | null;
message?: string | null;
};
export function dispatchDenSessionUpdated(detail: DenSessionUpdatedDetail) {
if (typeof window === "undefined") {
return;
}
window.dispatchEvent(
new CustomEvent<DenSessionUpdatedDetail>(denSessionUpdatedEvent, {
detail,
}),
);
}

View File

@@ -198,6 +198,35 @@ export function normalizeDenBaseUrl(input: string | null | undefined): string |
function isWebAppHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
if (
normalized === "localhost" ||
normalized === "0.0.0.0" ||
normalized === "::1" ||
normalized === "[::1]" ||
/^127(?:\.\d{1,3}){3}$/.test(normalized)
) {
return true;
}
const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const [first, second, third, fourth] = ipv4Match.slice(1).map(Number);
const octets = [first, second, third, fourth];
if (octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) {
if (
first === 10 ||
first === 127 ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168) ||
(first === 169 && second === 254) ||
(first === 100 && second >= 64 && second <= 127)
) {
return true;
}
}
}
return normalized === "app.openworklabs.com" || normalized === "app.openwork.software" || normalized.startsWith("app.");
}

View File

@@ -3,6 +3,7 @@ import { createEffect, createSignal, type Accessor } from "solid-js";
import { t, currentLocale } from "../../i18n";
import { createDenClient, writeDenSettings } from "../lib/den";
import { dispatchDenSessionUpdated } from "../lib/den-session-events";
import { stripBundleQuery } from "../bundles";
import type { createBundlesStore } from "../bundles/store";
import type { SettingsTab, View } from "../types";
@@ -35,6 +36,15 @@ export function createDeepLinksController(options: {
const [pendingDenAuthDeepLink, setPendingDenAuthDeepLink] = createSignal<DenAuthDeepLink | null>(null);
const [processingDenAuthDeepLink, setProcessingDenAuthDeepLink] = createSignal(false);
const recentClaimedDeepLinks = new Map<string, number>();
const recentHandledDenGrants = new Map<string, number>();
const pruneRecentHandledDenGrants = (now: number) => {
for (const [grant, seenAt] of recentHandledDenGrants) {
if (now - seenAt > 5 * 60 * 1000) {
recentHandledDenGrants.delete(grant);
}
}
};
const queueRemoteConnectDefaults = (pending: RemoteWorkspaceDefaults | null) => {
setPendingRemoteConnectDeepLink(pending);
@@ -87,6 +97,18 @@ export function createDeepLinksController(options: {
if (!parsed) {
return false;
}
const now = Date.now();
pruneRecentHandledDenGrants(now);
if (recentHandledDenGrants.has(parsed.grant)) {
return true;
}
const currentPending = pendingDenAuthDeepLink();
if (currentPending?.grant === parsed.grant) {
return true;
}
setPendingDenAuthDeepLink(parsed);
return true;
};
@@ -173,6 +195,7 @@ export function createDeepLinksController(options: {
setProcessingDenAuthDeepLink(true);
setPendingDenAuthDeepLink(null);
recentHandledDenGrants.set(pending.grant, Date.now());
options.setView("settings");
options.setSettingsTab("den");
options.goToSettings("den");
@@ -192,24 +215,20 @@ export function createDeepLinksController(options: {
activeOrgName: null,
});
window.dispatchEvent(
new CustomEvent("openwork-den-session-updated", {
detail: {
status: "success",
email: result.user?.email ?? null,
},
}),
);
dispatchDenSessionUpdated({
status: "success",
baseUrl: pending.denBaseUrl,
token: result.token,
user: result.user,
email: result.user?.email ?? null,
});
})
.catch((error) => {
window.dispatchEvent(
new CustomEvent("openwork-den-session-updated", {
detail: {
status: "error",
message: error instanceof Error ? error.message : t("app.error_cloud_signin", currentLocale()),
},
}),
);
recentHandledDenGrants.delete(pending.grant);
dispatchDenSessionUpdated({
status: "error",
message: error instanceof Error ? error.message : t("app.error_cloud_signin", currentLocale()),
});
})
.finally(() => {
setProcessingDenAuthDeepLink(false);

View File

@@ -1,21 +1,27 @@
import { db } from "./db.js"
import { env } from "./env.js"
import { sendDenOrganizationInvitationEmail, sendDenVerificationEmail } from "./email.js"
import { syncDenSignupContact } from "./loops.js"
import { db } from "./db.js";
import { env } from "./env.js";
import {
sendDenOrganizationInvitationEmail,
sendDenVerificationEmail,
} from "./email.js";
import { syncDenSignupContact } from "./loops.js";
import {
DEN_API_KEY_DEFAULT_PREFIX,
DEN_API_KEY_RATE_LIMIT_MAX,
DEN_API_KEY_RATE_LIMIT_TIME_WINDOW_MS,
} from "./api-keys.js"
import { denOrganizationAccess, denOrganizationStaticRoles } from "./organization-access.js"
import { seedDefaultOrganizationRoles } from "./orgs.js"
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import * as schema from "@openwork-ee/den-db/schema"
import { apiKey } from "@better-auth/api-key"
import { APIError } from "better-call"
import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { emailOTP, organization } from "better-auth/plugins"
} from "./api-keys.js";
import {
denOrganizationAccess,
denOrganizationStaticRoles,
} from "./organization-access.js";
import { seedDefaultOrganizationRoles } from "./orgs.js";
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid";
import * as schema from "@openwork-ee/den-db/schema";
import { apiKey } from "@better-auth/api-key";
import { APIError } from "better-call";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { emailOTP, organization } from "better-auth/plugins";
const socialProviders = {
...(env.github.clientId && env.github.clientSecret
@@ -34,29 +40,39 @@ const socialProviders = {
},
}
: {}),
}
};
function hasRole(roleValue: string, roleName: string) {
return roleValue
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
.includes(roleName)
.includes(roleName);
}
function getInvitationOrigin() {
return env.betterAuthTrustedOrigins.find((origin) => origin !== "*") ?? env.betterAuthUrl
return (
env.betterAuthTrustedOrigins.find((origin) => origin !== "*") ??
env.betterAuthUrl
);
}
function buildInvitationLink(invitationId: string) {
return new URL(`/join-org?invite=${encodeURIComponent(invitationId)}`, getInvitationOrigin()).toString()
return new URL(
`/join-org?invite=${encodeURIComponent(invitationId)}`,
getInvitationOrigin(),
).toString();
}
export const auth = betterAuth({
baseURL: env.betterAuthUrl,
secret: env.betterAuthSecret,
trustedOrigins: env.betterAuthTrustedOrigins.length > 0 ? env.betterAuthTrustedOrigins : undefined,
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
trustedOrigins:
env.betterAuthTrustedOrigins.length > 0
? env.betterAuthTrustedOrigins
: undefined,
socialProviders:
Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
database: drizzleAdapter(db, {
provider: "mysql",
schema,
@@ -70,32 +86,32 @@ export const auth = betterAuth({
generateId: (options) => {
switch (options.model) {
case "user":
return createDenTypeId("user")
return createDenTypeId("user");
case "session":
return createDenTypeId("session")
return createDenTypeId("session");
case "account":
return createDenTypeId("account")
return createDenTypeId("account");
case "verification":
return createDenTypeId("verification")
return createDenTypeId("verification");
case "apikey":
case "apiKey":
return createDenTypeId("apiKey")
return createDenTypeId("apiKey");
case "rateLimit":
return createDenTypeId("rateLimit")
return createDenTypeId("rateLimit");
case "organization":
return createDenTypeId("organization")
return createDenTypeId("organization");
case "member":
return createDenTypeId("member")
return createDenTypeId("member");
case "invitation":
return createDenTypeId("invitation")
return createDenTypeId("invitation");
case "team":
return createDenTypeId("team")
return createDenTypeId("team");
case "teamMember":
return createDenTypeId("teamMember")
return createDenTypeId("teamMember");
case "organizationRole":
return createDenTypeId("organizationRole")
return createDenTypeId("organizationRole");
default:
return false
return false;
}
},
},
@@ -135,7 +151,7 @@ export const auth = betterAuth({
await syncDenSignupContact({
email: user.email,
name: user.name,
})
});
},
},
emailAndPassword: {
@@ -150,14 +166,10 @@ export const auth = betterAuth({
expiresIn: 600,
allowedAttempts: 5,
async sendVerificationOTP({ email, otp, type }) {
if (type !== "email-verification") {
return
}
await sendDenVerificationEmail({
email,
verificationCode: otp,
})
});
},
}),
organization({
@@ -182,30 +194,33 @@ export const auth = betterAuth({
invitedByEmail: data.inviter.user.email,
organizationName: data.organization.name,
role: data.role,
})
});
},
organizationHooks: {
afterCreateOrganization: async ({ organization }) => {
await seedDefaultOrganizationRoles(normalizeDenTypeId("organization", organization.id))
await seedDefaultOrganizationRoles(
normalizeDenTypeId("organization", organization.id),
);
},
beforeRemoveMember: async ({ member }) => {
if (hasRole(member.role, "owner")) {
throw new APIError("BAD_REQUEST", {
message: "The organization owner cannot be removed.",
})
});
}
},
beforeUpdateMemberRole: async ({ member, newRole }) => {
if (hasRole(member.role, "owner")) {
throw new APIError("BAD_REQUEST", {
message: "The organization owner role cannot be changed.",
})
});
}
if (hasRole(newRole, "owner")) {
throw new APIError("BAD_REQUEST", {
message: "Owner can only be assigned during organization creation.",
})
message:
"Owner can only be assigned during organization creation.",
});
}
},
},
@@ -224,4 +239,4 @@ export const auth = betterAuth({
},
}),
],
})
});

View File

@@ -46,6 +46,35 @@ function readSingleHeader(value: string | null) {
function isWebAppHost(hostname: string) {
const normalized = hostname.trim().toLowerCase()
if (
normalized === "localhost"
|| normalized === "0.0.0.0"
|| normalized === "::1"
|| normalized === "[::1]"
|| /^127(?:\.\d{1,3}){3}$/.test(normalized)
) {
return true
}
const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
if (ipv4Match) {
const [first, second, third, fourth] = ipv4Match.slice(1).map(Number)
const octets = [first, second, third, fourth]
if (octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) {
if (
first === 10
|| first === 127
|| (first === 172 && second >= 16 && second <= 31)
|| (first === 192 && second === 168)
|| (first === 169 && second === 254)
|| (first === 100 && second >= 64 && second <= 127)
) {
return true
}
}
}
return normalized === "app.openworklabs.com"
|| normalized === "app.openwork.software"
|| normalized.startsWith("app.")
@@ -178,51 +207,75 @@ export function registerDesktopAuthRoutes<T extends { Variables: AuthContextVari
const input = c.req.valid("json")
const now = new Date()
const rows = await db
.select({
grant: DesktopHandoffGrantTable,
session: AuthSessionTable,
user: AuthUserTable,
})
.from(DesktopHandoffGrantTable)
.innerJoin(AuthSessionTable, eq(DesktopHandoffGrantTable.session_token, AuthSessionTable.token))
.innerJoin(AuthUserTable, eq(DesktopHandoffGrantTable.user_id, AuthUserTable.id))
.where(
and(
eq(DesktopHandoffGrantTable.id, input.grant),
isNull(DesktopHandoffGrantTable.consumed_at),
gt(DesktopHandoffGrantTable.expires_at, now),
gt(AuthSessionTable.expiresAt, now),
),
)
.limit(1)
const exchange = await db.transaction(async (tx) => {
const rows = await tx
.select({
session: AuthSessionTable,
user: AuthUserTable,
})
.from(DesktopHandoffGrantTable)
.innerJoin(AuthSessionTable, eq(DesktopHandoffGrantTable.session_token, AuthSessionTable.token))
.innerJoin(AuthUserTable, eq(DesktopHandoffGrantTable.user_id, AuthUserTable.id))
.where(
and(
eq(DesktopHandoffGrantTable.id, input.grant),
isNull(DesktopHandoffGrantTable.consumed_at),
gt(DesktopHandoffGrantTable.expires_at, now),
gt(AuthSessionTable.expiresAt, now),
),
)
.limit(1)
const row = rows[0]
if (!row) {
const row = rows[0]
if (!row) {
return null
}
const consumedAt = new Date()
await tx
.update(DesktopHandoffGrantTable)
.set({ consumed_at: consumedAt })
.where(
and(
eq(DesktopHandoffGrantTable.id, input.grant),
isNull(DesktopHandoffGrantTable.consumed_at),
gt(DesktopHandoffGrantTable.expires_at, now),
),
)
const claimed = await tx
.select({ id: DesktopHandoffGrantTable.id })
.from(DesktopHandoffGrantTable)
.where(
and(
eq(DesktopHandoffGrantTable.id, input.grant),
eq(DesktopHandoffGrantTable.consumed_at, consumedAt),
),
)
.limit(1)
if (!claimed[0]) {
return null
}
return {
token: row.session.token,
user: {
id: row.user.id,
email: row.user.email,
name: row.user.name,
},
}
})
if (!exchange) {
return c.json({
error: "grant_not_found",
message: "This desktop sign-in link is missing, expired, or already used.",
}, 404)
}
await db
.update(DesktopHandoffGrantTable)
.set({ consumed_at: now })
.where(
and(
eq(DesktopHandoffGrantTable.id, input.grant),
isNull(DesktopHandoffGrantTable.consumed_at),
),
)
return c.json({
token: row.session.token,
user: {
id: row.user.id,
email: row.user.email,
name: row.user.name,
},
})
return c.json(exchange)
},
)
}