Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/onboard-rescan-and-success-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"everything-dev": patch
---

Auth plugin onboard page: a rescan by someone who has left the community now says they're no longer a member instead of "You're in". The page checks a fresh organization list against the redeemed organization ID, clears a stale active organization, and shows a retryable error if the check fails. The redeem's refusal for a member who left now carries `data.action: "rejoin-organization"`. Child apps can configure the joined screen through the `onboard` auth variable: `links.next`/`links.home` (with an `{organizationId}` placeholder) and `links.explore` for the success and error panels (defaults stay `/build`, `/dashboard`, `/explore`; `next: false` hides the build link), plus `community.url`, an app endpoint that returns the joined community's page and channels for a "Visit {community}" button and channel links.
6 changes: 6 additions & 0 deletions plugins/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ It runs inside the **everything-plugin** framework (oRPC + Effect) and is design
| `siwn.relayer.testnet.accountId` | `string` | — | Optional testnet relayer account ID |
| `siwn.subAccount.mainnet.parentAccount` | `string` | — | Optional mainnet sub-account parent |
| `siwn.subAccount.testnet.parentAccount` | `string` | — | Optional testnet sub-account parent |
| `onboard.links.next` | `{ href, label? } \| false` | `/build`, "Get build prompts" | First link on the onboard joined screen; `false` hides it |
| `onboard.links.home` | `{ href, label? }` | `/dashboard`, "Go to Home" | Last link on the onboard joined screen |
| `onboard.links.explore` | `{ href, label? }` | `/explore`, "Explore communities" | Link on the onboard error and status panels |
| `onboard.community.url` | `string` | — | App path returning `{ name?, href?, channels?: [{ label, url }] }` for the joined organization; renders "Visit {community}" and channel links |

`onboard` hrefs must be app paths (`/…`) or `https://` URLs. `links.next`, `links.home` and `community.url` replace `{organizationId}` with the joined organization's ID. A failed or invalid `community.url` response shows nothing extra.

### Plugin Secrets

Expand Down
2 changes: 2 additions & 0 deletions plugins/auth/src/config-schemas.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DEFAULT_DEVICE_LINK_CLIENT_ID } from "better-near-auth";
import { z } from "zod";
import { onboardConfigSchema } from "./onboard-config";
export const API_KEY_CONFIG_IDS = ["user-keys", "org-keys"] as const;

export const subAccountNetworkSchema = z.object({
Expand Down Expand Up @@ -142,6 +143,7 @@ export const authVariablesSchema = z.object({
from: z.string(),
})
.optional(),
onboard: onboardConfigSchema.optional(),
});

export const authSecretsSchema = z.object({
Expand Down
61 changes: 61 additions & 0 deletions plugins/auth/src/onboard-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { z } from "zod";

export const MEMBERSHIP_ENDED_ACTION = "rejoin-organization";

export const ONBOARD_ORGANIZATION_ID_PLACEHOLDER = "{organizationId}";

const SAFE_HREF = /^(\/(?![/\\])|https:\/\/)/;

const onboardHrefSchema = z
.string()
.trim()
.regex(SAFE_HREF, 'must be an app path ("/…") or an https:// URL');

export const onboardLinkSchema = z.object({
href: onboardHrefSchema,
label: z.string().trim().min(1).optional(),
});

export const onboardConfigSchema = z.object({
links: z
.object({
next: z.union([z.literal(false), onboardLinkSchema]).optional(),
home: onboardLinkSchema.optional(),
explore: onboardLinkSchema
.refine(
(link) => !link.href.includes(ONBOARD_ORGANIZATION_ID_PLACEHOLDER),
"explore links have no organization to fill in",
)
.optional(),
})
.optional(),
community: z
.object({
url: onboardHrefSchema.refine((url) => url.startsWith("/"), "must be an app path"),
})
.optional(),
});

export const onboardCommunitySchema = z.object({
name: z.string().trim().min(1).optional(),
href: onboardHrefSchema.optional(),
channels: z
.array(
z.object({
label: z.string().trim().min(1),
url: z.string().regex(/^https?:\/\//),
}),
)
.optional(),
});

export type OnboardLink = z.infer<typeof onboardLinkSchema>;
export type OnboardConfig = z.infer<typeof onboardConfigSchema>;
export type OnboardCommunity = z.infer<typeof onboardCommunitySchema>;

export function fillOrganizationId(template: string, organizationId: string): string {
return template.replaceAll(
ONBOARD_ORGANIZATION_ID_PLACEHOLDER,
encodeURIComponent(organizationId),
);
}
12 changes: 12 additions & 0 deletions plugins/auth/src/services/domain-errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ORPCError } from "@orpc/server";
import { Data, Effect, Schema } from "effect";
import { MEMBERSHIP_ENDED_ACTION } from "../onboard-config";

export class AuthApiError extends Data.TaggedError("AuthApiError")<{
code: string;
Expand Down Expand Up @@ -81,6 +82,10 @@ export class NotOrgMember extends Schema.TaggedError<NotOrgMember>()("NotOrgMemb
message: Schema.String,
}) {}

export class MembershipEnded extends Schema.TaggedError<MembershipEnded>()("MembershipEnded", {
message: Schema.String,
}) {}

export class NotOrganizer extends Schema.TaggedError<NotOrganizer>()("NotOrganizer", {
message: Schema.String,
}) {}
Expand Down Expand Up @@ -187,6 +192,7 @@ const FAILURE_TAGS = new Set([
"TeamNotFound",
"TeamUpdateFailed",
"NotOrgMember",
"MembershipEnded",
"NotOrganizer",
"PlatformAdminRequired",
"EventEnded",
Expand Down Expand Up @@ -230,6 +236,7 @@ export type AuthFailure =
| TeamNotFound
| TeamUpdateFailed
| NotOrgMember
| MembershipEnded
| NotOrganizer
| PlatformAdminRequired
| EventEnded
Expand Down Expand Up @@ -306,6 +313,11 @@ export function toTransportError(error: AuthFailure): ORPCError<string, unknown>
return new ORPCError("INTERNAL_SERVER_ERROR", { message: error.message });
case "NotOrgMember":
return new ORPCError("FORBIDDEN", { message: error.message, data: {} });
case "MembershipEnded":
return new ORPCError("FORBIDDEN", {
message: error.message,
data: { action: MEMBERSHIP_ENDED_ACTION },
});
case "NotOrganizer":
return new ORPCError("FORBIDDEN", {
message: error.message,
Expand Down
4 changes: 2 additions & 2 deletions plugins/auth/src/services/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
CodeRevoked,
EventEnded,
EventTeamCreateFailed,
NotOrgMember,
MembershipEnded,
OrgFull,
OrgRequiresApproval,
PlatformAdminRequired,
Expand Down Expand Up @@ -486,7 +486,7 @@ export const buildOnboardingService = ({
}),
);
if (!membership) {
return yield* new NotOrgMember({
return yield* new MembershipEnded({
message: "You are no longer a member of this organization",
});
}
Expand Down
7 changes: 6 additions & 1 deletion plugins/auth/tests/integration/onboarding.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import * as schema from "../../src/db/schema";
import { MEMBERSHIP_ENDED_ACTION } from "../../src/onboard-config";
import {
addTestMember,
createTestHandlers,
Expand Down Expand Up @@ -290,7 +291,11 @@ describe("onboarding handlers", () => {
input: { code: code.code },
context: { reqHeaders: newcomer.reqHeaders },
}),
).rejects.toThrow(/no longer a member/i);
).rejects.toMatchObject({
code: "FORBIDDEN",
message: expect.stringMatching(/no longer a member/i),
data: { action: MEMBERSHIP_ENDED_ACTION },
});

const member = await services.services.db.query.member.findFirst({
where: and(
Expand Down
34 changes: 34 additions & 0 deletions plugins/auth/ui/src/i18n/catalogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ export const englishLoginMessages = {
"auth.device.approve.key": "Approve key",
"auth.device.approve.approve": "Approve",
"auth.device.approve.deny": "Deny",
"auth.onboard.verifying": "Checking your membership…",
"auth.onboard.left.title": "No longer a member",
"auth.onboard.left.description":
"You’re no longer a member of this community. Ask an organizer for a new code or an invitation.",
"auth.onboard.verifyFailed.title": "Couldn’t check your membership",
"auth.onboard.verifyFailed.description":
"We couldn’t confirm your membership. Check your connection and try again.",
"auth.onboard.visit": "Visit {community}",
"auth.onboard.channels": "{community} channels",
} as const;

export type LoginMessageId = keyof typeof englishLoginMessages;
Expand Down Expand Up @@ -232,6 +241,15 @@ const spanishLoginMessages = {
"auth.device.approve.key": "Aprobar clave",
"auth.device.approve.approve": "Aprobar",
"auth.device.approve.deny": "Rechazar",
"auth.onboard.verifying": "Comprobando tu membresía…",
"auth.onboard.left.title": "Ya no eres miembro",
"auth.onboard.left.description":
"Ya no eres miembro de esta comunidad. Pide a un organizador un código nuevo o una invitación.",
"auth.onboard.verifyFailed.title": "No pudimos comprobar tu membresía",
"auth.onboard.verifyFailed.description":
"No pudimos confirmar tu membresía. Revisa tu conexión e inténtalo de nuevo.",
"auth.onboard.visit": "Visitar {community}",
"auth.onboard.channels": "Canales de {community}",
} satisfies Record<LoginMessageId, string>;

const frenchLoginMessages = {
Expand Down Expand Up @@ -342,6 +360,15 @@ const frenchLoginMessages = {
"auth.device.approve.key": "Approuver la clé",
"auth.device.approve.approve": "Approuver",
"auth.device.approve.deny": "Refuser",
"auth.onboard.verifying": "Vérification de votre adhésion…",
"auth.onboard.left.title": "Vous n’êtes plus membre",
"auth.onboard.left.description":
"Vous n’êtes plus membre de cette communauté. Demandez un nouveau code ou une invitation à un organisateur.",
"auth.onboard.verifyFailed.title": "Impossible de vérifier votre adhésion",
"auth.onboard.verifyFailed.description":
"Nous n’avons pas pu confirmer votre adhésion. Vérifiez votre connexion et réessayez.",
"auth.onboard.visit": "Visiter {community}",
"auth.onboard.channels": "Canaux de {community}",
} satisfies Record<LoginMessageId, string>;

const chineseLoginMessages = {
Expand Down Expand Up @@ -441,6 +468,13 @@ const chineseLoginMessages = {
"auth.device.approve.key": "批准密钥",
"auth.device.approve.approve": "批准",
"auth.device.approve.deny": "拒绝",
"auth.onboard.verifying": "正在确认你的成员身份…",
"auth.onboard.left.title": "你已不再是成员",
"auth.onboard.left.description": "你已不再是这个社区的成员。请向组织者索取新的邀请码或邀请。",
"auth.onboard.verifyFailed.title": "无法确认你的成员身份",
"auth.onboard.verifyFailed.description": "我们无法确认你的成员身份。请检查网络连接后重试。",
"auth.onboard.visit": "访问 {community}",
"auth.onboard.channels": "{community} 的频道",
} satisfies Record<LoginMessageId, string>;

const translatedLoginMessages: Record<LoginLocale, Partial<Record<LoginMessageId, string>>> = {
Expand Down
54 changes: 54 additions & 0 deletions plugins/auth/ui/src/lib/onboard-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { RouterContext } from "@/context";
import {
fillOrganizationId,
type OnboardCommunity,
type OnboardConfig,
type OnboardLink,
onboardCommunitySchema,
onboardConfigSchema,
} from "../../../src/onboard-config";

export type { OnboardCommunity, OnboardLink };

export const DEFAULT_ONBOARD_LINKS = {
next: { href: "/build", label: "Get build prompts" },
home: { href: "/dashboard", label: "Go to Home" },
explore: { href: "/explore", label: "Explore communities" },
} as const;

export function readOnboardConfig(runtimeConfig: RouterContext["runtimeConfig"]): OnboardConfig {
const parsed = onboardConfigSchema.safeParse(runtimeConfig?.auth?.variables?.onboard);
return parsed.success ? parsed.data : {};
}

export function resolveOnboardLinks(config: OnboardConfig, organizationId?: string) {
const fill = (link: OnboardLink, fallbackLabel: string) => ({
href: organizationId ? fillOrganizationId(link.href, organizationId) : link.href,
label: link.label ?? fallbackLabel,
});
const next = config.links?.next;
return {
next:
next === false
? null
: fill(next ?? DEFAULT_ONBOARD_LINKS.next, DEFAULT_ONBOARD_LINKS.next.label),
home: fill(config.links?.home ?? DEFAULT_ONBOARD_LINKS.home, DEFAULT_ONBOARD_LINKS.home.label),
explore: fill(
config.links?.explore ?? DEFAULT_ONBOARD_LINKS.explore,
DEFAULT_ONBOARD_LINKS.explore.label,
),
};
}

export async function fetchOnboardCommunity(
urlTemplate: string,
organizationId: string,
): Promise<OnboardCommunity | null> {
const response = await fetch(fillOrganizationId(urlTemplate, organizationId), {
credentials: "same-origin",
headers: { accept: "application/json" },
});
if (!response.ok) return null;
const parsed = onboardCommunitySchema.safeParse(await response.json());
return parsed.success ? parsed.data : null;
}
35 changes: 35 additions & 0 deletions plugins/auth/ui/src/lib/onboard-membership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { QueryClient } from "@tanstack/react-query";
import { type AuthClient, refreshSessionCache } from "everything-dev/ui/auth";
import type { AuthApiClient } from "./api";

export const organizationsQueryKey = ["organizations"] as const;

export async function verifyOrganizationMembership({
apiClient,
auth,
queryClient,
organizationId,
}: {
apiClient: Pick<AuthApiClient, "listOrganizations" | "setActiveOrganization">;
auth: AuthClient;
queryClient: QueryClient;
organizationId?: string;
}): Promise<boolean> {
const [organizations, session] = await Promise.all([
queryClient.fetchQuery({
queryKey: organizationsQueryKey,
queryFn: () => apiClient.listOrganizations(),
staleTime: 0,
}),
refreshSessionCache(auth, queryClient),
]);
const memberOf = new Set(organizations.map((organization) => organization.id));

const activeOrganizationId = session?.session.activeOrganizationId;
if (activeOrganizationId && !memberOf.has(activeOrganizationId)) {
await apiClient.setActiveOrganization({ organizationId: null });
await refreshSessionCache(auth, queryClient);
}

return !!organizationId && memberOf.has(organizationId);
}
Loading
Loading