From 3a82416dfa0c7eecbb1f2dcabbb0b5af7b93c3e1 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 13 Aug 2026 13:55:46 -0500 Subject: [PATCH 01/12] wip --- .env.example | 3 - api/src/contract.ts | 30 ++ .../db/migrations/0002_awesome_madame_web.sql | 3 + api/src/db/migrations/meta/0002_snapshot.json | 176 ++++++++ api/src/db/migrations/meta/_journal.json | 9 +- api/src/db/schema.ts | 13 +- api/src/index.ts | 10 + api/src/services/tenants.ts | 52 ++- api/tests/unit/tenants-service.test.ts | 56 +++ bos.config.json | 3 - host/.env.example | 3 - host/src/services/binding-resolver.ts | 148 +++++++ host/src/services/tenant-runtime.ts | 164 +------- .../integration/tenant-host-nested.test.ts | 45 ++- host/tests/integration/tenant-runtime.test.ts | 322 ++++++++++----- tests/regression/http/tenant_bindings_test.go | 184 +++++++++ ui/src/routes/_layout.tsx | 380 +----------------- ui/src/routes/_layout/_authenticated.tsx | 251 +++++++++++- .../_layout/_authenticated/admin/index.tsx | 104 +++++ .../_layout/_authenticated/admin/system.tsx | 73 ++++ ui/src/routes/_layout/_public.tsx | 53 +++ ui/src/routes/_layout/{ => _public}/about.tsx | 2 +- ui/src/routes/_layout/_public/index.tsx | 111 +++++ ui/src/routes/_layout/{ => _public}/login.tsx | 50 +-- ui/src/routes/_layout/index.tsx | 122 ------ ui/src/routes/_layout/skill.tsx | 126 ------ 26 files changed, 1563 insertions(+), 930 deletions(-) create mode 100644 api/src/db/migrations/0002_awesome_madame_web.sql create mode 100644 api/src/db/migrations/meta/0002_snapshot.json create mode 100644 host/src/services/binding-resolver.ts create mode 100644 tests/regression/http/tenant_bindings_test.go create mode 100644 ui/src/routes/_layout/_authenticated/admin/index.tsx create mode 100644 ui/src/routes/_layout/_authenticated/admin/system.tsx create mode 100644 ui/src/routes/_layout/_public.tsx rename ui/src/routes/_layout/{ => _public}/about.tsx (99%) create mode 100644 ui/src/routes/_layout/_public/index.tsx rename ui/src/routes/_layout/{ => _public}/login.tsx (86%) delete mode 100644 ui/src/routes/_layout/index.tsx delete mode 100644 ui/src/routes/_layout/skill.tsx diff --git a/.env.example b/.env.example index 450772d4..452789fd 100644 --- a/.env.example +++ b/.env.example @@ -3,9 +3,6 @@ # app.host CORS_ORIGIN=http://localhost:4100 -TENANT_WHITELIST= -ALLOW_OVERRIDE= -ALLOW_UNTRUSTED_SSR= CSP_STRICT= # app.api diff --git a/api/src/contract.ts b/api/src/contract.ts index 5887408c..92bc79b2 100644 --- a/api/src/contract.ts +++ b/api/src/contract.ts @@ -20,6 +20,9 @@ export const TenantSchema = z.object({ orgId: z.string(), name: z.string(), status: TenantStatusSchema, + allowUiOverrides: z.boolean(), + allowBackendOverrides: z.boolean(), + allowSsr: z.boolean(), createdAt: z.string(), updatedAt: z.string(), deletedAt: z.string().nullable(), @@ -27,6 +30,17 @@ export const TenantSchema = z.object({ export type Tenant = z.infer; +export const TenantBindingSchema = z.object({ + hostname: z + .string() + .describe("Subdomain hostname that routes to this tenant on the parent domain"), + accountId: z.string(), + allowUiOverrides: z.boolean(), + allowBackendOverrides: z.boolean(), + allowSsr: z.boolean(), + status: TenantStatusSchema, +}); + const ThingSchema = z.object({ thingId: z.string().describe("Unique identifier for the thing"), type: z.string().describe("Plugin-derived thing type"), @@ -80,6 +94,9 @@ export const contract = oc.router({ name: z.string(), accountId: z.string(), status: z.enum(["active", "pending"]).optional(), + allowUiOverrides: z.boolean().default(true), + allowBackendOverrides: z.boolean().default(false), + allowSsr: z.boolean().default(false), }), ) .output(TenantSchema) @@ -94,6 +111,9 @@ export const contract = oc.router({ subdomain: z.string().optional(), accountId: z.string().optional(), status: TenantStatusSchema.optional(), + allowUiOverrides: z.boolean().optional(), + allowBackendOverrides: z.boolean().optional(), + allowSsr: z.boolean().optional(), }), ) .output(TenantSchema) @@ -128,6 +148,16 @@ export const contract = oc.router({ .output(TenantSchema) .errors({ NOT_FOUND }), + listTenantBindings: oc + .route({ + method: "GET", + path: "/tenants/bindings", + summary: "List all active tenant domain bindings", + description: + "Public — returns the subdomain-to-config mapping used by the host's BindingResolver.", + }) + .output(z.array(TenantBindingSchema)), + tenantPreflight: oc .route({ method: "POST", path: "/tenants/preflight" }) .input( diff --git a/api/src/db/migrations/0002_awesome_madame_web.sql b/api/src/db/migrations/0002_awesome_madame_web.sql new file mode 100644 index 00000000..e39a3327 --- /dev/null +++ b/api/src/db/migrations/0002_awesome_madame_web.sql @@ -0,0 +1,3 @@ +ALTER TABLE "tenants" ADD COLUMN "allow_ui_overrides" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "tenants" ADD COLUMN "allow_backend_overrides" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "tenants" ADD COLUMN "allow_ssr" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/api/src/db/migrations/meta/0002_snapshot.json b/api/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..fda7cde0 --- /dev/null +++ b/api/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,176 @@ +{ + "id": "7b8a421a-d0c7-414a-96e8-0c259935178b", + "prevId": "3e88c17e-53d5-4809-b872-33d2f87a8a90", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subdomain": { + "name": "subdomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "tenant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "allow_ui_overrides": { + "name": "allow_ui_overrides", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_backend_overrides": { + "name": "allow_backend_overrides", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_ssr": { + "name": "allow_ssr", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tenants_subdomain_idx": { + "name": "tenants_subdomain_idx", + "columns": [ + { + "expression": "subdomain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tenants_account_id_idx": { + "name": "tenants_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenants_subdomain_unique": { + "name": "tenants_subdomain_unique", + "nullsNotDistinct": false, + "columns": [ + "subdomain" + ] + }, + "tenants_account_id_unique": { + "name": "tenants_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id" + ] + }, + "tenants_org_id_unique": { + "name": "tenants_org_id_unique", + "nullsNotDistinct": false, + "columns": [ + "org_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.tenant_status": { + "name": "tenant_status", + "schema": "public", + "values": [ + "active", + "pending", + "suspended", + "pending_deletion" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/api/src/db/migrations/meta/_journal.json b/api/src/db/migrations/meta/_journal.json index c939ec4a..9e5e42bb 100644 --- a/api/src/db/migrations/meta/_journal.json +++ b/api/src/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1786078137908, "tag": "0001_brief_magik", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1786645659094, + "tag": "0002_awesome_madame_web", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index b141f76b..c689380d 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -1,4 +1,12 @@ -import { pgEnum, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { + boolean, + pgEnum, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; export const tenantStatus = pgEnum("tenant_status", [ "active", @@ -16,6 +24,9 @@ export const tenants = pgTable( orgId: text("org_id").notNull().unique(), name: text("name").notNull(), status: tenantStatus("status").default("active").notNull(), + allowUiOverrides: boolean("allow_ui_overrides").default(true).notNull(), + allowBackendOverrides: boolean("allow_backend_overrides").default(false).notNull(), + allowSsr: boolean("allow_ssr").default(false).notNull(), createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), deletedAt: timestamp("deleted_at", { mode: "date", withTimezone: true }), diff --git a/api/src/index.ts b/api/src/index.ts index df235000..9ebbaed8 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -144,6 +144,9 @@ export default createPlugin.withPlugins()({ accountId: input.accountId, orgId: context.organization.activeOrganizationId, status: input.status, + allowUiOverrides: input.allowUiOverrides, + allowBackendOverrides: input.allowBackendOverrides, + allowSsr: input.allowSsr, }); }), @@ -159,6 +162,9 @@ export default createPlugin.withPlugins()({ subdomain: input.subdomain, accountId: input.accountId, status: input.status, + allowUiOverrides: input.allowUiOverrides, + allowBackendOverrides: input.allowBackendOverrides, + allowSsr: input.allowSsr, }); }), @@ -223,6 +229,10 @@ export default createPlugin.withPlugins()({ return tenant; }), + listTenantBindings: builder.listTenantBindings.handler(async () => + services.tenants.listBindings(), + ), + tenantPreflight: builder.tenantPreflight.use(requireAuth).handler(async ({ input }) => { const subdomainValid = SUBDOMAIN_SEGMENT_REGEX.test(input.subdomain); const accountId = `${input.subdomain}.${input.parentAccount}`; diff --git a/api/src/services/tenants.ts b/api/src/services/tenants.ts index 2754489d..4cdb1aa5 100644 --- a/api/src/services/tenants.ts +++ b/api/src/services/tenants.ts @@ -13,25 +13,46 @@ export interface TenantRecord { orgId: string; name: string; status: TenantStatus; + allowUiOverrides: boolean; + allowBackendOverrides: boolean; + allowSsr: boolean; createdAt: string; updatedAt: string; deletedAt: string | null; } +export interface TenantBinding { + hostname: string; + accountId: string; + allowUiOverrides: boolean; + allowBackendOverrides: boolean; + allowSsr: boolean; + status: TenantStatus; +} + export interface TenantInput { subdomain: string; name: string; accountId: string; orgId: string; status?: TenantStatus; + allowUiOverrides?: boolean; + allowBackendOverrides?: boolean; + allowSsr?: boolean; } export interface TenantsService { listTenantsByOrgIds(orgIds: string[]): Promise; + listBindings(): Promise; createTenant(input: TenantInput): Promise; updateTenant( id: string, - input: Partial>, + input: Partial< + Pick< + TenantInput, + "name" | "subdomain" | "accountId" | "status" | "allowUiOverrides" | "allowBackendOverrides" | "allowSsr" + > + >, ): Promise; softDeleteTenant(id: string): Promise; suspendTenant(id: string): Promise; @@ -55,6 +76,9 @@ function toTenantRecord(row: TenantRow): TenantRecord { orgId: row.orgId, name: row.name, status: row.status, + allowUiOverrides: row.allowUiOverrides, + allowBackendOverrides: row.allowBackendOverrides, + allowSsr: row.allowSsr, createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt), updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt), deletedAt: row.deletedAt instanceof Date ? row.deletedAt.toISOString() : null, @@ -88,6 +112,27 @@ export const TenantsLive = Layer.effect( } }, + listBindings: async () => { + try { + const rows = await db + .select({ + subdomain: tenantsTable.subdomain, + accountId: tenantsTable.accountId, + allowUiOverrides: tenantsTable.allowUiOverrides, + allowBackendOverrides: tenantsTable.allowBackendOverrides, + allowSsr: tenantsTable.allowSsr, + status: tenantsTable.status, + }) + .from(tenantsTable); + return rows.map(({ subdomain, ...binding }) => ({ + ...binding, + hostname: subdomain, + })); + } catch (error) { + throw toOrpcError(error); + } + }, + createTenant: async (input) => { try { const [row] = await db @@ -98,6 +143,11 @@ export const TenantsLive = Layer.effect( accountId: input.accountId, orgId: input.orgId, ...(input.status !== undefined && { status: input.status }), + ...(input.allowUiOverrides !== undefined && { allowUiOverrides: input.allowUiOverrides }), + ...(input.allowBackendOverrides !== undefined && { + allowBackendOverrides: input.allowBackendOverrides, + }), + ...(input.allowSsr !== undefined && { allowSsr: input.allowSsr }), }) .onConflictDoNothing() .returning(); diff --git a/api/tests/unit/tenants-service.test.ts b/api/tests/unit/tenants-service.test.ts index 427ad07f..fd04bd4b 100644 --- a/api/tests/unit/tenants-service.test.ts +++ b/api/tests/unit/tenants-service.test.ts @@ -147,6 +147,62 @@ describe("TenantsService", () => { expect(await runService(layer, (svc) => svc.listTenantsByOrgIds([]))).toEqual([]); }); + it("lists active and non-deleted bindings with default permissions", async () => { + const layer = freshLayer(); + await runService(layer, (svc) => + svc.createTenant({ + ...baseInput, + subdomain: "acme", + accountId: "acme.example.near", + }), + ); + const suspended = await runService(layer, (svc) => + svc.createTenant({ + ...baseInput, + subdomain: "beta", + accountId: "beta.example.near", + orgId: "org-2", + }), + ); + await runService(layer, (svc) => svc.suspendTenant(suspended.id)); + + const bindings = await runService(layer, (svc) => svc.listBindings()); + + expect(bindings).toHaveLength(2); + expect(bindings.find((b) => b.hostname === "acme")).toMatchObject({ + hostname: "acme", + accountId: "acme.example.near", + allowUiOverrides: true, + allowBackendOverrides: false, + allowSsr: false, + status: "active", + }); + expect(bindings.find((b) => b.hostname === "beta")?.status).toBe("suspended"); + }); + + it("persists allow_* overrides on create and update", async () => { + const layer = freshLayer(); + const created = await runService(layer, (svc) => + svc.createTenant({ + ...baseInput, + allowUiOverrides: false, + allowBackendOverrides: true, + allowSsr: true, + }), + ); + expect(created).toMatchObject({ + allowUiOverrides: false, + allowBackendOverrides: true, + allowSsr: true, + }); + + const updated = await runService(layer, (svc) => + svc.updateTenant(created.id, { allowSsr: false }), + ); + expect(updated.allowSsr).toBe(false); + expect(updated.allowUiOverrides).toBe(false); + }); + it("updates a tenant name", async () => { const layer = freshLayer(); const created = await runService(layer, (svc) => svc.createTenant(baseInput)); diff --git a/bos.config.json b/bos.config.json index b31993ec..436021f2 100644 --- a/bos.config.json +++ b/bos.config.json @@ -18,9 +18,6 @@ "production": "https://elliot-braem-7253-host-everything-dev-nearbuilder-aa7d0803c-ze.zephyrcloud.app", "secrets": [ "CORS_ORIGIN", - "TENANT_WHITELIST", - "ALLOW_OVERRIDE", - "ALLOW_UNTRUSTED_SSR", "CSP_STRICT" ] }, diff --git a/host/.env.example b/host/.env.example index 1283ab78..f5d82332 100644 --- a/host/.env.example +++ b/host/.env.example @@ -1,8 +1,5 @@ # app.host CORS_ORIGIN=http://localhost:3000 -TENANT_WHITELIST= -ALLOW_OVERRIDE= -ALLOW_UNTRUSTED_SSR= CSP_STRICT= # app.api diff --git a/host/src/services/binding-resolver.ts b/host/src/services/binding-resolver.ts new file mode 100644 index 00000000..30ed5ee4 --- /dev/null +++ b/host/src/services/binding-resolver.ts @@ -0,0 +1,148 @@ +import { logger } from "../utils/logger"; +import { resolveDomain } from "../utils/normalize"; +import type { RuntimeConfig } from "./config"; + +export type TenantBindingStatus = "active" | "pending" | "suspended" | "pending_deletion"; + +export interface TenantBinding { + hostname: string; + accountId: string; + allowUiOverrides: boolean; + allowBackendOverrides: boolean; + allowSsr: boolean; + status: TenantBindingStatus; +} + +const BINDINGS_TTL_MS = 30_000; + +interface CachedBindings { + apiUrl: string; + expiresAt: number; + entries: Map; + refetching?: Promise>; +} + +let bindingsCache: CachedBindings | null = null; + +export function clearBindingResolverCache() { + bindingsCache = null; +} + +function isBaseHost(hostname: string, gatewayId: string): boolean { + const normalized = hostname.toLowerCase(); + return ( + normalized === gatewayId || + normalized === "localhost" || + normalized === "127.0.0.1" + ); +} + +async function fetchBindingsFromApi(apiUrl: string): Promise { + const endpoint = `${apiUrl.replace(/\/$/, "")}/api/tenants/bindings`; + let response: Response; + try { + response = await fetch(endpoint); + } catch (cause) { + throw new Error(`Failed to reach API at ${endpoint}: ${String(cause)}`, { cause }); + } + + if (!response.ok) { + throw new Error(`GET ${endpoint} failed with HTTP ${response.status}`); + } + + return (await response.json()) as TenantBinding[]; +} + +function resolveGatewayId(config: RuntimeConfig): string { + return resolveDomain(config.domain, config.host.url).toLowerCase(); +} + +function mapBindingsByHostname( + config: RuntimeConfig, + bindings: TenantBinding[], +): Map { + const gatewayId = resolveGatewayId(config); + const entries = new Map(); + for (const binding of bindings) { + entries.set(`${binding.hostname.toLowerCase()}.${gatewayId}`, binding); + } + return entries; +} + +function ensureBindingsLoaded( + config: RuntimeConfig, +): Promise> { + const apiUrl = config.api?.url; + if (!apiUrl) { + return Promise.resolve(new Map()); + } + + const now = Date.now(); + if ( + bindingsCache && + bindingsCache.apiUrl === apiUrl && + bindingsCache.expiresAt > now && + bindingsCache.entries.size > 0 + ) { + return Promise.resolve(bindingsCache.entries); + } + + if (bindingsCache?.apiUrl === apiUrl && bindingsCache.refetching) { + return bindingsCache.refetching; + } + + const staleEntries = + bindingsCache?.apiUrl === apiUrl ? bindingsCache.entries : null; + + const fetchPromise = fetchBindingsFromApi(apiUrl) + .then((bindings) => { + const entries = mapBindingsByHostname(config, bindings); + bindingsCache = { apiUrl, expiresAt: Date.now() + BINDINGS_TTL_MS, entries }; + return entries; + }) + .catch((cause) => { + if (staleEntries && staleEntries.size > 0) { + logger.error( + `[BindingResolver] Refresh failed, serving ${staleEntries.size} stale binding(s): ${cause instanceof Error ? cause.message : String(cause)}`, + ); + bindingsCache = { + apiUrl, + expiresAt: Date.now() + BINDINGS_TTL_MS, + entries: staleEntries, + }; + return staleEntries; + } + bindingsCache = null; + throw cause; + }); + + bindingsCache = { + apiUrl, + expiresAt: now + BINDINGS_TTL_MS, + entries: staleEntries ?? new Map(), + refetching: fetchPromise, + }; + + return fetchPromise; +} + +export interface BindingResolver { + resolve(hostname: string): Promise; + clear(): void; +} + +export function createBindingResolver(config: RuntimeConfig): BindingResolver { + return { + async resolve(hostname: string): Promise { + const normalized = hostname.toLowerCase(); + const gatewayId = resolveGatewayId(config); + if (isBaseHost(normalized, gatewayId)) { + return null; + } + + const entries = await ensureBindingsLoaded(config); + return entries.get(normalized) ?? null; + }, + clear: clearBindingResolverCache, + }; +} diff --git a/host/src/services/tenant-runtime.ts b/host/src/services/tenant-runtime.ts index 5909f09b..4f3b0cae 100644 --- a/host/src/services/tenant-runtime.ts +++ b/host/src/services/tenant-runtime.ts @@ -1,28 +1,25 @@ import { buildRuntimeConfig, - isRuntimeOverrideAllowed, loadRemoteConfig, - parseRuntimeOverrideTargets, type RuntimeConfig, } from "everything-dev/config"; import { verifySriForUrl } from "everything-dev/integrity"; import type { RuntimePlugin } from "../types"; import { logger } from "../utils/logger"; import { resolveDomain } from "../utils/normalize"; +import { createBindingResolver, type BindingResolver } from "./binding-resolver"; const REMOTE_CONFIG_TTL_MS = 30_000; const VERIFICATION_TTL_MS = 5 * 60_000; const MAX_REMOTE_CONFIG_CACHE_SIZE = 256; const MAX_VERIFICATION_CACHE_SIZE = 512; -const NEAR_ACCOUNT_ID_REGEX = - /^(?=.{2,64}$)([a-z0-9]+(?:[-_][a-z0-9]+)*)(\.([a-z0-9]+(?:[-_][a-z0-9]+)*))*$/; -type RuntimeOverrideTarget = ReturnType[number]; type BosEnv = "development" | "production" | "staging"; type IntegrityVerificationMode = "blocking" | "stale-while-revalidate"; interface ResolveRequestRuntimeOptions { verification?: IntegrityVerificationMode; + bindingResolver?: BindingResolver; } interface CachedRemoteConfig { @@ -55,9 +52,6 @@ export class TenantRuntimeError extends Error { const remoteConfigCache = new Map(); const verifiedUiCache = new Map(); -const unsupportedOverrideWarnings = new Set(); -let tenantWhitelistCache: { raw: string; value: Set } | null = null; -let allowedOverridesCache: { raw: string; value: RuntimeOverrideTarget[] } | null = null; function pruneExpiredCacheEntries( cache: Map, @@ -96,92 +90,6 @@ export function getTenantRuntimeErrorResponse(error: unknown): { status: number; export function clearTenantRuntimeCaches() { remoteConfigCache.clear(); verifiedUiCache.clear(); - unsupportedOverrideWarnings.clear(); - tenantWhitelistCache = null; - allowedOverridesCache = null; -} - -function parseBoolean(value: string | undefined): boolean { - if (!value) return false; - return ["1", "true", "yes", "on"].includes(value.toLowerCase()); -} - -function getTenantWhitelist(): Set { - const raw = process.env.TENANT_WHITELIST ?? ""; - if (tenantWhitelistCache?.raw === raw) { - return tenantWhitelistCache.value; - } - - const value = new Set( - raw - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean), - ); - tenantWhitelistCache = { raw, value }; - return value; -} - -function getAllowedOverrides(): RuntimeOverrideTarget[] { - const raw = process.env.ALLOW_OVERRIDE ?? ""; - if (allowedOverridesCache?.raw === raw) { - return allowedOverridesCache.value; - } - - const value = parseRuntimeOverrideTargets(raw); - allowedOverridesCache = { raw, value }; - return value; -} - -function warnUnsupportedOverrideTargets(targets: ReadonlyArray) { - for (const target of targets) { - if (target === "ui" || target === "plugins" || target.startsWith("plugins.")) { - continue; - } - - if (!unsupportedOverrideWarnings.has(target)) { - unsupportedOverrideWarnings.add(target); - logger.warn( - `[Tenant Runtime] Ignoring unsupported override target "${target}" in fixed-core mode`, - ); - } - } -} - -function resolveTenantAccountId( - hostname: string, - gatewayId: string, - namespaceAccountId: string, -): string | null { - const normalizedHost = hostname.toLowerCase(); - const normalizedGateway = gatewayId.toLowerCase(); - const normalizedNamespaceAccountId = namespaceAccountId.toLowerCase(); - - if ( - normalizedHost === normalizedGateway || - normalizedHost === "localhost" || - normalizedHost === "127.0.0.1" - ) { - return null; - } - - const suffix = `.${normalizedGateway}`; - if (!normalizedHost.endsWith(suffix)) { - return null; - } - - const tenantLabel = normalizedHost.slice(0, -suffix.length); - const tenantSegments = tenantLabel.split(".").filter(Boolean); - if (tenantSegments.length === 0 || tenantSegments.join(".") !== tenantLabel) { - throw new TenantRuntimeError(`Invalid tenant host: ${hostname}`, 404); - } - - const accountId = `${tenantSegments.join(".")}.${normalizedNamespaceAccountId}`; - if (!NEAR_ACCOUNT_ID_REGEX.test(accountId)) { - throw new TenantRuntimeError(`Invalid tenant account: ${accountId}`, 404); - } - - return accountId; } function getRemoteConfigCached(bosUrl: string, env: BosEnv) { @@ -347,16 +255,6 @@ async function verifyPluginUiIntegrity( ); } -function isPluginOverrideAllowed( - allowedOverrides: ReadonlyArray, - pluginKey: string, -): boolean { - return ( - isRuntimeOverrideAllowed(allowedOverrides, "plugins") || - isRuntimeOverrideAllowed(allowedOverrides, `plugins.${pluginKey}`) - ); -} - function buildEffectivePluginConfig( basePlugin: RuntimePlugin, tenantPlugin: RuntimePlugin, @@ -372,10 +270,9 @@ function buildEffectiveRuntimeConfig( baseConfig: RuntimeConfig, tenantConfig: RuntimeConfig, tenantAccountId: string, - allowedOverrides: ReadonlyArray, + allowUiOverrides: boolean, + allowBackendOverrides: boolean, ): RuntimeConfig { - warnUnsupportedOverrideTargets(allowedOverrides); - const effectiveConfig: RuntimeConfig = { ...baseConfig, account: tenantAccountId, @@ -385,7 +282,7 @@ function buildEffectiveRuntimeConfig( repository: tenantConfig.repository, }; - if (isRuntimeOverrideAllowed(allowedOverrides, "ui")) { + if (allowUiOverrides) { effectiveConfig.ui = tenantConfig.ui; } @@ -399,7 +296,7 @@ function buildEffectiveRuntimeConfig( continue; } - if (!isPluginOverrideAllowed(allowedOverrides, pluginKey)) { + if (!allowBackendOverrides) { continue; } @@ -412,29 +309,6 @@ function buildEffectiveRuntimeConfig( return effectiveConfig; } -function matchesTenantPattern(accountId: string, pattern: string): boolean { - if (pattern === accountId) return true; - if (pattern.startsWith("*.") && accountId.endsWith(pattern.slice(1))) return true; - return false; -} - -function isSsrAllowed(accountId: string): boolean { - if (parseBoolean(process.env.ALLOW_UNTRUSTED_SSR)) { - return true; - } - - const whitelist = getTenantWhitelist(); - for (const entry of whitelist) { - if (matchesTenantPattern(accountId, entry)) return true; - } - return false; -} - -function getTenantStatus(remoteConfig: Awaited>): string { - const raw = remoteConfig.rawConfig as { status?: string } | undefined; - return raw?.status ?? "active"; -} - export async function resolveRequestRuntime( baseConfig: RuntimeConfig, request: Request, @@ -443,8 +317,9 @@ export async function resolveRequestRuntime( const verificationMode = options?.verification ?? "blocking"; const url = new URL(request.url); const gatewayId = resolveDomain(baseConfig.domain, baseConfig.host.url); - const tenantAccountId = resolveTenantAccountId(url.hostname, gatewayId, baseConfig.account); - if (!tenantAccountId) { + const bindingResolver = options?.bindingResolver ?? createBindingResolver(baseConfig); + const binding = await bindingResolver.resolve(url.hostname); + if (!binding) { return { config: baseConfig, tenantAccountId: null, @@ -453,6 +328,14 @@ export async function resolveRequestRuntime( }; } + const tenantAccountId = binding.accountId; + if (binding.status === "suspended") { + throw new TenantRuntimeError("Tenant is suspended", 503); + } + if (binding.status === "pending_deletion") { + throw new TenantRuntimeError("Tenant has been deleted", 410); + } + const bosUrl = `bos://${tenantAccountId}/${gatewayId}`; const remoteConfig = await getRemoteConfigCached(bosUrl, "production"); const baseBosUrl = `bos://${baseConfig.account}/${gatewayId}`; @@ -483,17 +366,10 @@ export async function resolveRequestRuntime( baseConfig, tenantRuntimeConfig, tenantAccountId, - getAllowedOverrides(), + binding.allowUiOverrides, + binding.allowBackendOverrides, ); - const tenantStatus = getTenantStatus(remoteConfig); - if (tenantStatus === "suspended") { - throw new TenantRuntimeError("Tenant is suspended", 503); - } - if (tenantStatus === "pending_deletion") { - throw new TenantRuntimeError("Tenant has been deleted", 410); - } - if (effectiveConfig.ui.url !== baseConfig.ui.url) { await verifyUiIntegrity(effectiveConfig, verificationMode); } @@ -512,7 +388,7 @@ export async function resolveRequestRuntime( const ssrAllowed = Boolean(effectiveConfig.ui.ssrUrl) && Boolean(effectiveConfig.ui.ssrIntegrity) && - isSsrAllowed(tenantAccountId); + binding.allowSsr; return { config: ssrAllowed diff --git a/host/tests/integration/tenant-host-nested.test.ts b/host/tests/integration/tenant-host-nested.test.ts index e2891320..ccde7073 100644 --- a/host/tests/integration/tenant-host-nested.test.ts +++ b/host/tests/integration/tenant-host-nested.test.ts @@ -11,19 +11,6 @@ vi.mock("everything-dev/config", async () => { await vi.importActual("everything-dev/config"); return { ...actual, - parseRuntimeOverrideTargets: (value?: string | null) => - value - ? [ - ...new Set( - value - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean), - ), - ] - : [], - isRuntimeOverrideAllowed: (targets: string[], target: string) => - targets.includes(target) || (target.startsWith("plugins.") && targets.includes("plugins.*")), loadRemoteConfig: loadRemoteConfigMock, buildRuntimeConfig: buildRuntimeConfigMock, }; @@ -39,6 +26,29 @@ vi.mock("everything-dev/integrity", async () => { }; }); +vi.mock("../../src/services/binding-resolver", async () => { + const actual = await vi.importActual( + "../../src/services/binding-resolver", + ); + return { + ...actual, + createBindingResolver: () => ({ + resolve: async (hostname: string) => + hostname === "chicago.alice.linktree.com" + ? { + hostname: "chicago.alice.linktree.com", + accountId: "chicago.alice.linktree.near", + allowUiOverrides: true, + allowBackendOverrides: false, + allowSsr: false, + status: "active", + } + : null, + clear: () => {}, + }), + }; +}); + const { runServer } = await import("../../src/program"); function createBaseConfig() { @@ -117,9 +127,6 @@ describe("tenant host nested integration", () => { const previousNodeEnv = process.env.NODE_ENV; const previousHost = process.env.HOST; const previousPort = process.env.PORT; - const previousAllowOverride = process.env.ALLOW_OVERRIDE; - const previousTenantWhitelist = process.env.TENANT_WHITELIST; - const previousAllowUntrustedSsr = process.env.ALLOW_UNTRUSTED_SSR; beforeAll(async () => { assetServer = await startStaticServer({}); @@ -129,9 +136,6 @@ describe("tenant host nested integration", () => { process.env.NODE_ENV = "production"; process.env.HOST = "127.0.0.1"; process.env.PORT = String(port); - process.env.ALLOW_OVERRIDE = "ui,plugins.*"; - process.env.TENANT_WHITELIST = "chicago.alice.linktree.near"; - process.env.ALLOW_UNTRUSTED_SSR = "false"; process.argv.push("--proxy"); const config = createBaseConfig(); @@ -162,9 +166,6 @@ describe("tenant host nested integration", () => { process.env.NODE_ENV = previousNodeEnv; process.env.HOST = previousHost; process.env.PORT = previousPort; - process.env.ALLOW_OVERRIDE = previousAllowOverride; - process.env.TENANT_WHITELIST = previousTenantWhitelist; - process.env.ALLOW_UNTRUSTED_SSR = previousAllowUntrustedSsr; const proxyIdx = process.argv.indexOf("--proxy"); if (proxyIdx !== -1) { diff --git a/host/tests/integration/tenant-runtime.test.ts b/host/tests/integration/tenant-runtime.test.ts index 9927d32e..1cbf21ec 100644 --- a/host/tests/integration/tenant-runtime.test.ts +++ b/host/tests/integration/tenant-runtime.test.ts @@ -7,19 +7,6 @@ const verifySriForUrlMock = vi.fn(); vi.mock("everything-dev/config", async () => { return { - parseRuntimeOverrideTargets: (value?: string | null) => - value - ? [ - ...new Set( - value - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean), - ), - ] - : [], - isRuntimeOverrideAllowed: (targets: string[], target: string) => - targets.includes(target) || (target.startsWith("plugins.") && targets.includes("plugins.*")), loadRemoteConfig: loadRemoteConfigMock, buildRuntimeConfig: buildRuntimeConfigMock, }; @@ -32,6 +19,7 @@ vi.mock("everything-dev/integrity", () => ({ const { clearTenantRuntimeCaches, resolveRequestRuntime } = await import( "../../src/services/tenant-runtime" ); +import type { BindingResolver } from "../../src/services/binding-resolver"; function createDeferred() { let resolve!: (value: T | PromiseLike) => void; @@ -41,6 +29,34 @@ function createDeferred() { return { promise, resolve }; } +function createMockBindingResolver( + ...hostnames: Array<{ + hostname: string; + allowUiOverrides?: boolean; + allowBackendOverrides?: boolean; + allowSsr?: boolean; + status?: "active" | "pending" | "suspended" | "pending_deletion"; + }> +): BindingResolver { + const map = new Map( + hostnames.map((entry) => [ + entry.hostname, + { + hostname: entry.hostname, + accountId: entry.hostname.replace(/\.com$/, ".near"), + allowUiOverrides: entry.allowUiOverrides ?? true, + allowBackendOverrides: entry.allowBackendOverrides ?? false, + allowSsr: entry.allowSsr ?? false, + status: entry.status ?? "active", + }, + ]), + ); + return { + resolve: async (hostname: string) => map.get(hostname) ?? null, + clear: () => {}, + }; +} + function createBaseRuntimeConfig(): RuntimeConfig { return { env: "production", @@ -96,35 +112,27 @@ function createBaseRuntimeConfig(): RuntimeConfig { } describe("resolveRequestRuntime", () => { - const envSnapshot = { ...process.env }; - beforeEach(() => { vi.clearAllMocks(); clearTenantRuntimeCaches(); verifySriForUrlMock.mockResolvedValue(undefined); - process.env = { - ...envSnapshot, - ALLOW_OVERRIDE: "ui", - TENANT_WHITELIST: "alice.linktree.near", - ALLOW_UNTRUSTED_SSR: "false", - }; - }); - - afterEach(() => { - process.env = { ...envSnapshot }; }); it("returns the base runtime on the bare domain", async () => { const baseConfig = createBaseRuntimeConfig(); - const result = await resolveRequestRuntime(baseConfig, new Request("https://linktree.com/")); + const result = await resolveRequestRuntime( + baseConfig, + new Request("https://linktree.com/"), + { bindingResolver: createMockBindingResolver() }, + ); expect(result.config).toBe(baseConfig); expect(result.tenantAccountId).toBeNull(); expect(loadRemoteConfigMock).not.toHaveBeenCalled(); }); - it("derives tenant accounts relative to the active runtime account", async () => { + it("resolves the tenant account for a hostname via the binding resolver", async () => { loadRemoteConfigMock.mockResolvedValue({ source: "bos://alice.linktree.near/linktree.com", rawConfig: { @@ -155,6 +163,12 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( createBaseRuntimeConfig(), new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + }), + }, ); expect(result.tenantAccountId).toBe("alice.linktree.near"); @@ -164,7 +178,7 @@ describe("resolveRequestRuntime", () => { ); }); - it("supports nested tenant labels within the active runtime namespace", async () => { + it("resolves nested tenant hostnames via the binding resolver", async () => { loadRemoteConfigMock.mockResolvedValue({ source: "bos://chicago.alice.linktree.near/linktree.com", rawConfig: { @@ -198,6 +212,12 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( createBaseRuntimeConfig(), new Request("https://chicago.alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "chicago.alice.linktree.com", + allowUiOverrides: true, + }), + }, ); expect(result.tenantAccountId).toBe("chicago.alice.linktree.near"); @@ -230,61 +250,37 @@ describe("resolveRequestRuntime", () => { buildRuntimeConfigMock.mockResolvedValue(createBaseRuntimeConfig()); await expect( - resolveRequestRuntime(createBaseRuntimeConfig(), new Request("https://alice.linktree.com/")), + resolveRequestRuntime(createBaseRuntimeConfig(), new Request("https://alice.linktree.com/"), { + bindingResolver: createMockBindingResolver({ hostname: "alice.linktree.com" }), + }), ).rejects.toThrow("must extend bos://linktree.near/linktree.com"); }); - it("rejects a suspended tenant with 503 based on published config status", async () => { - loadRemoteConfigMock.mockResolvedValue({ - source: "bos://alice.linktree.near/linktree.com", - rawConfig: { - extends: "bos://linktree.near/linktree.com", - status: "suspended", - }, - config: { - account: "alice.linktree.near", - app: { - host: { development: "local:host", production: "https://host.example.com" }, - ui: { name: "ui", production: "https://cdn.example.com/alice-ui" }, - api: { name: "api", production: "https://api.example.com" }, - }, - }, - extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], - }); - - buildRuntimeConfigMock.mockResolvedValue(createBaseRuntimeConfig()); - + it("rejects a suspended tenant with 503 based on the binding status", async () => { await expect( - resolveRequestRuntime(createBaseRuntimeConfig(), new Request("https://alice.linktree.com/")), + resolveRequestRuntime(createBaseRuntimeConfig(), new Request("https://alice.linktree.com/"), { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + status: "suspended", + }), + }), ).rejects.toMatchObject({ status: 503, message: "Tenant is suspended" }); + expect(loadRemoteConfigMock).not.toHaveBeenCalled(); }); - it("rejects a pending_deletion tenant with 410 based on published config status", async () => { - loadRemoteConfigMock.mockResolvedValue({ - source: "bos://alice.linktree.near/linktree.com", - rawConfig: { - extends: "bos://linktree.near/linktree.com", - status: "pending_deletion", - }, - config: { - account: "alice.linktree.near", - app: { - host: { development: "local:host", production: "https://host.example.com" }, - ui: { name: "ui", production: "https://cdn.example.com/alice-ui" }, - api: { name: "api", production: "https://api.example.com" }, - }, - }, - extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], - }); - - buildRuntimeConfigMock.mockResolvedValue(createBaseRuntimeConfig()); - + it("rejects a pending_deletion tenant with 410 based on the binding status", async () => { await expect( - resolveRequestRuntime(createBaseRuntimeConfig(), new Request("https://alice.linktree.com/")), + resolveRequestRuntime(createBaseRuntimeConfig(), new Request("https://alice.linktree.com/"), { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + status: "pending_deletion", + }), + }), ).rejects.toMatchObject({ status: 410, message: "Tenant has been deleted" }); + expect(loadRemoteConfigMock).not.toHaveBeenCalled(); }); - it("applies a tenant UI override and allows SSR for whitelisted tenants", async () => { + it("applies a tenant UI override and allows SSR when the binding enables both", async () => { const baseConfig = createBaseRuntimeConfig(); loadRemoteConfigMock.mockResolvedValue({ @@ -325,6 +321,13 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( baseConfig, new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }, ); expect(result.tenantAccountId).toBe("alice.linktree.near"); @@ -338,7 +341,7 @@ describe("resolveRequestRuntime", () => { ); }); - it("disables SSR for non-whitelisted tenants when untrusted SSR is off", async () => { + it("disables SSR when the binding does not allow it", async () => { const baseConfig = createBaseRuntimeConfig(); loadRemoteConfigMock.mockResolvedValue({ @@ -373,6 +376,13 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( baseConfig, new Request("https://bob.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "bob.linktree.com", + allowUiOverrides: true, + allowSsr: false, + }), + }, ); expect(result.ssrAllowed).toBe(false); @@ -380,7 +390,7 @@ describe("resolveRequestRuntime", () => { expect(result.config.ui.ssrIntegrity).toBeUndefined(); }); - it("disables SSR for whitelisted tenants when ssrIntegrity is missing", async () => { + it("disables SSR for SSR-enabled bindings when ssrIntegrity is missing", async () => { const baseConfig = createBaseRuntimeConfig(); loadRemoteConfigMock.mockResolvedValue({ @@ -419,6 +429,13 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( baseConfig, new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }, ); expect(result.ssrAllowed).toBe(false); @@ -426,7 +443,7 @@ describe("resolveRequestRuntime", () => { expect(result.config.ui.ssrIntegrity).toBeUndefined(); }); - it("disables SSR for whitelisted tenants when ssrUrl is missing", async () => { + it("disables SSR for SSR-enabled bindings when ssrUrl is missing", async () => { const baseConfig = createBaseRuntimeConfig(); loadRemoteConfigMock.mockResolvedValue({ @@ -460,6 +477,13 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( baseConfig, new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }, ); expect(result.ssrAllowed).toBe(false); @@ -467,7 +491,7 @@ describe("resolveRequestRuntime", () => { expect(result.config.ui.ssrIntegrity).toBeUndefined(); }); - it("allows SSR for whitelisted tenants when both ssrUrl and ssrIntegrity are present", async () => { + it("allows SSR when the binding enables SSR and both ssrUrl and ssrIntegrity are present", async () => { const baseConfig = createBaseRuntimeConfig(); loadRemoteConfigMock.mockResolvedValue({ @@ -508,6 +532,13 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( baseConfig, new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }, ); expect(result.ssrAllowed).toBe(true); @@ -515,9 +546,8 @@ describe("resolveRequestRuntime", () => { expect(result.config.ui.ssrIntegrity).toBe("sha384-alice-ssr"); }); - it("applies existing plugin UI overrides when plugins are allowed", async () => { + it("applies existing plugin UI overrides when backend overrides are allowed", async () => { const baseConfig = createBaseRuntimeConfig(); - process.env.ALLOW_OVERRIDE = "ui,plugins.*"; loadRemoteConfigMock.mockResolvedValue({ source: "bos://alice.linktree.near/linktree.com", @@ -591,6 +621,14 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( baseConfig, new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + allowBackendOverrides: true, + }), + }, ); expect(result.config.plugins?.apps.ui?.url).toBe("https://plugins.example.com/alice-apps-ui"); @@ -639,7 +677,17 @@ describe("resolveRequestRuntime", () => { }, }); - await resolveRequestRuntime(baseConfig, new Request("https://alice.linktree.com/")); + await resolveRequestRuntime( + baseConfig, + new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }, + ); const refresh = createDeferred(); verifySriForUrlMock.mockImplementationOnce(() => refresh.promise); @@ -648,6 +696,11 @@ describe("resolveRequestRuntime", () => { await expect( resolveRequestRuntime(baseConfig, new Request("https://alice.linktree.com/asset.js"), { verification: "stale-while-revalidate", + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), }), ).resolves.toMatchObject({ tenantAccountId: "alice.linktree.near" }); expect(verifySriForUrlMock).toHaveBeenCalledTimes(2); @@ -655,6 +708,11 @@ describe("resolveRequestRuntime", () => { await expect( resolveRequestRuntime(baseConfig, new Request("https://alice.linktree.com/asset-2.js"), { verification: "stale-while-revalidate", + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), }), ).resolves.toMatchObject({ tenantAccountId: "alice.linktree.near" }); expect(verifySriForUrlMock).toHaveBeenCalledTimes(2); @@ -704,7 +762,17 @@ describe("resolveRequestRuntime", () => { }, }); - await resolveRequestRuntime(baseConfig, new Request("https://alice.linktree.com/")); + await resolveRequestRuntime( + baseConfig, + new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }, + ); const refresh = createDeferred(); verifySriForUrlMock.mockImplementationOnce(() => refresh.promise); @@ -716,6 +784,11 @@ describe("resolveRequestRuntime", () => { new Request("https://alice.linktree.com/"), { verification: "blocking", + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), }, ).then(() => { settled = true; @@ -732,50 +805,109 @@ describe("resolveRequestRuntime", () => { } }); - it("recomputes the tenant whitelist when the env value changes", async () => { + it("gates SSR per tenant based on the binding allowSsr flag", async () => { const baseConfig = createBaseRuntimeConfig(); loadRemoteConfigMock.mockResolvedValue({ - source: "bos://bob.linktree.near/linktree.com", + source: "bos://alice.linktree.near/linktree.com", rawConfig: { extends: "bos://linktree.near/linktree.com", }, config: { - account: "bob.linktree.near", + account: "alice.linktree.near", app: { host: { development: "local:host", production: "https://host.example.com" }, - ui: { name: "ui", production: "https://cdn.example.com/bob-ui" }, + ui: { name: "ui", production: "https://cdn.example.com/alice-ui" }, api: { name: "api", production: "https://api.example.com" }, }, }, - extendsChain: ["bos://bob.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], + extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], }); buildRuntimeConfigMock.mockResolvedValue({ ...baseConfig, - account: "bob.linktree.near", + account: "alice.linktree.near", ui: { ...baseConfig.ui, - url: "https://cdn.example.com/bob-ui", - entry: "https://cdn.example.com/bob-ui/mf-manifest.json", - integrity: "sha384-bob", - ssrUrl: "https://cdn.example.com/bob-ui-ssr", - ssrIntegrity: "sha384-bob-ssr", + url: "https://cdn.example.com/alice-ui", + entry: "https://cdn.example.com/alice-ui/mf-manifest.json", + integrity: "sha384-alice", + ssrUrl: "https://cdn.example.com/alice-ui-ssr", + ssrIntegrity: "sha384-alice-ssr", }, }); const blocked = await resolveRequestRuntime( baseConfig, - new Request("https://bob.linktree.com/"), + new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: false, + }), + }, ); expect(blocked.ssrAllowed).toBe(false); - - process.env.TENANT_WHITELIST = "bob.linktree.near"; + expect(blocked.config.ui.ssrUrl).toBeUndefined(); const allowed = await resolveRequestRuntime( baseConfig, - new Request("https://bob.linktree.com/"), + new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }, ); expect(allowed.ssrAllowed).toBe(true); + expect(allowed.config.ui.ssrUrl).toBe("https://cdn.example.com/alice-ui-ssr"); + }); + + it("does not apply tenant UI overrides when the binding disallows them", async () => { + const baseConfig = createBaseRuntimeConfig(); + + loadRemoteConfigMock.mockResolvedValue({ + source: "bos://alice.linktree.near/linktree.com", + rawConfig: { + extends: "bos://linktree.near/linktree.com", + }, + config: { + account: "alice.linktree.near", + app: { + host: { development: "local:host", production: "https://host.example.com" }, + ui: { name: "ui", production: "https://cdn.example.com/alice-ui" }, + api: { name: "api", production: "https://api.example.com" }, + }, + }, + extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], + }); + + buildRuntimeConfigMock.mockResolvedValue({ + ...baseConfig, + account: "alice.linktree.near", + ui: { + ...baseConfig.ui, + url: "https://cdn.example.com/alice-ui", + entry: "https://cdn.example.com/alice-ui/mf-manifest.json", + integrity: "sha384-alice", + }, + }); + + const result = await resolveRequestRuntime( + baseConfig, + new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: false, + }), + }, + ); + + expect(result.config.ui.url).toBe(baseConfig.ui.url); + expect(verifySriForUrlMock).not.toHaveBeenCalled(); }); }); diff --git a/tests/regression/http/tenant_bindings_test.go b/tests/regression/http/tenant_bindings_test.go new file mode 100644 index 00000000..7ca4c801 --- /dev/null +++ b/tests/regression/http/tenant_bindings_test.go @@ -0,0 +1,184 @@ +package regression + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + "everything.dev/regression/http/internal/regtest" +) + +func TestTenantBindingsPublicEndpoint(t *testing.T) { + client := regtest.NewCookieClient() + + t.Run("bindings_return_200_without_auth", func(t *testing.T) { + status, _, body := regtest.GetRaw(t, client, baseURL+"/api/tenants/bindings") + regtest.MustStatus(t, status, 200, body) + }) + + var bindings []struct { + Hostname string `json:"hostname"` + AccountID string `json:"accountId"` + AllowUiOverrides bool `json:"allowUiOverrides"` + AllowBackendOverrides bool `json:"allowBackendOverrides"` + AllowSsr bool `json:"allowSsr"` + Status string `json:"status"` + } + t.Run("bindings_decode_as_array", func(t *testing.T) { + status, _, body := regtest.GetRaw(t, client, baseURL+"/api/tenants/bindings") + regtest.MustStatus(t, status, 200, body) + if err := json.Unmarshal([]byte(body), &bindings); err != nil { + t.Fatalf("decoding bindings response: %v\nBody: %s", err, body) + } + }) + + t.Run("seeded_tenant_binding_present", func(t *testing.T) { + var seeded *struct { + Hostname string `json:"hostname"` + AccountID string `json:"accountId"` + AllowUiOverrides bool `json:"allowUiOverrides"` + AllowBackendOverrides bool `json:"allowBackendOverrides"` + AllowSsr bool `json:"allowSsr"` + Status string `json:"status"` + } + for i := range bindings { + if strings.HasPrefix(bindings[i].Hostname, "regression-tenant-") { + seeded = &bindings[i] + break + } + } + if seeded == nil { + t.Fatal("expected seeded tenant binding to be present") + } + if seeded.Status != "active" { + t.Fatalf("expected seeded tenant status 'active', got %q", seeded.Status) + } + if seeded.AccountID != fmt.Sprintf("%s.testnet", seeded.Hostname) { + t.Fatalf("expected account id derived from hostname, got %q", seeded.AccountID) + } + + // The seeded tenant uses the default permission columns. + if !seeded.AllowUiOverrides { + t.Fatal("expected allowUiOverrides to default to true") + } + if seeded.AllowBackendOverrides { + t.Fatal("expected allowBackendOverrides to default to false") + } + if seeded.AllowSsr { + t.Fatal("expected allowSsr to default to false") + } + }) +} + +func TestTenantBindingsReflectAllowFlags(t *testing.T) { + client := regtest.NewCookieClient() + + t.Run("sign_in", func(t *testing.T) { + status, _, body := regtest.PostEmpty(t, client, baseURL+"/api/auth/sign-in/anonymous") + regtest.MustStatus(t, status, 200, body) + }) + + orgName := fmt.Sprintf("regression-flags-org-%d", os.Getpid()) + t.Run("create_org", func(t *testing.T) { + status, _, body := regtest.PostJSON(t, client, baseURL+"/api/auth/organization/create", map[string]string{ + "name": orgName, + "slug": orgName, + }, map[string]string{ + "Origin": "http://localhost:4100", + }) + regtest.MustStatus(t, status, 200, body) + var result struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(body), &result); err != nil { + t.Fatalf("decoding org response: %v\nBody: %s", err, body) + } + if result.ID == "" { + t.Fatal("expected non-empty org id") + } + + status, _, body = regtest.PostJSON(t, client, baseURL+"/api/auth/organization/set-active", map[string]string{ + "organizationId": result.ID, + }, map[string]string{ + "Origin": "http://localhost:4100", + }) + regtest.MustStatus(t, status, 200, body) + }) + + subdomain := fmt.Sprintf("regression-flags-%d", os.Getpid()) + t.Run("create_tenant_with_explicit_flags", func(t *testing.T) { + status, _, body := regtest.PostJSON(t, client, baseURL+"/api/tenants", map[string]any{ + "subdomain": subdomain, + "name": "Flags Tenant", + "accountId": fmt.Sprintf("%s.testnet", subdomain), + "allowUiOverrides": false, + "allowBackendOverrides": true, + "allowSsr": true, + }, nil) + regtest.MustStatus(t, status, 200, body) + + var result struct { + ID string `json:"id"` + AllowUiOverrides bool `json:"allowUiOverrides"` + AllowBackendOverrides bool `json:"allowBackendOverrides"` + AllowSsr bool `json:"allowSsr"` + } + if err := json.Unmarshal([]byte(body), &result); err != nil { + t.Fatalf("decoding tenant response: %v\nBody: %s", err, body) + } + if result.ID == "" { + t.Fatal("expected non-empty tenant id") + } + if result.AllowUiOverrides { + t.Fatal("expected allowUiOverrides to be false") + } + if !result.AllowBackendOverrides { + t.Fatal("expected allowBackendOverrides to be true") + } + if !result.AllowSsr { + t.Fatal("expected allowSsr to be true") + } + }) + + t.Run("bindings_reflect_flags", func(t *testing.T) { + status, _, body := regtest.GetRaw(t, client, baseURL+"/api/tenants/bindings") + regtest.MustStatus(t, status, 200, body) + + var bindings []struct { + Hostname string `json:"hostname"` + AllowUiOverrides bool `json:"allowUiOverrides"` + AllowBackendOverrides bool `json:"allowBackendOverrides"` + AllowSsr bool `json:"allowSsr"` + } + if err := json.Unmarshal([]byte(body), &bindings); err != nil { + t.Fatalf("decoding bindings response: %v\nBody: %s", err, body) + } + + var found *struct { + Hostname string `json:"hostname"` + AllowUiOverrides bool `json:"allowUiOverrides"` + AllowBackendOverrides bool `json:"allowBackendOverrides"` + AllowSsr bool `json:"allowSsr"` + } + for i := range bindings { + if bindings[i].Hostname == subdomain { + found = &bindings[i] + break + } + } + if found == nil { + t.Fatal("expected created tenant to appear in bindings") + } + if found.AllowUiOverrides { + t.Fatal("expected binding allowUiOverrides to be false") + } + if !found.AllowBackendOverrides { + t.Fatal("expected binding allowBackendOverrides to be true") + } + if !found.AllowSsr { + t.Fatal("expected binding allowSsr to be true") + } + }) +} diff --git a/ui/src/routes/_layout.tsx b/ui/src/routes/_layout.tsx index db4a8ec0..c0a44b0a 100644 --- a/ui/src/routes/_layout.tsx +++ b/ui/src/routes/_layout.tsx @@ -1,97 +1,14 @@ -import { useQuery } from "@tanstack/react-query"; -import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router"; -import { ClipboardList, Compass, Globe, Home, Menu, Shield, X } from "lucide-react"; +import { createFileRoute, Outlet, useRouterState } from "@tanstack/react-router"; +import { X } from "lucide-react"; import { useEffect, useState } from "react"; -import { - getAccount, - getActiveRuntime, - getAppName, - sessionQueryOptions, - useAuthClient, -} from "@/app"; -import builtOn from "@/assets/built_on.png"; -import builtOnRev from "@/assets/built_on_rev.png"; -import { ThemeToggle } from "@/components/theme-toggle"; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { UserNav } from "@/components/user-nav"; -import { cn } from "@/lib/utils"; - -type SidebarRole = "anon" | "member" | "admin"; - -interface SidebarItem { - icon: React.ComponentType<{ className?: string }>; - label: string; - to: string; - roleRequired: SidebarRole; -} - -function filterSidebarByRole(items: SidebarItem[], userRole: SidebarRole): SidebarItem[] { - return items.filter((item) => { - if (item.roleRequired === "anon") return true; - if (item.roleRequired === "member" && userRole !== "anon") return true; - if (item.roleRequired === "admin" && userRole === "admin") return true; - return false; - }); -} - -function getUserRole(isAuthenticated: boolean, isAdmin: boolean): SidebarRole { - if (isAdmin) return "admin"; - if (isAuthenticated) return "member"; - return "anon"; -} +import { TooltipProvider } from "@/components/ui/tooltip"; export const Route = createFileRoute("/_layout")({ - beforeLoad: async ({ context }) => { - const { queryClient, authClient, apiClient } = context; - const session = await queryClient.ensureQueryData( - sessionQueryOptions(authClient, context.session), - ); - - const accountId = getAccount(context.runtimeConfig); - const tenant = await apiClient.resolveTenant({ accountId }); - - return { - runtimeConfig: context.runtimeConfig, - session, - tenant, - }; - }, component: Layout, }); function Layout() { - const pathname = useRouterState({ select: (s) => s.location.pathname }); const isNavigating = useRouterState({ select: (s) => s.status === "pending" }); - const { runtimeConfig, session, tenant } = Route.useRouteContext(); - const appName = getAppName(runtimeConfig); - const runtime = getActiveRuntime(runtimeConfig); - const account = getAccount(runtimeConfig); - const auth = useAuthClient(); - const isAuthenticated = !!session?.user; - const userRole = getUserRole(isAuthenticated, session?.user?.role === "admin"); - - const { data: liveSession } = useQuery({ - ...sessionQueryOptions(auth, session), - initialData: session, - }); - const activeOrgId = liveSession?.session?.activeOrganizationId ?? null; - const liveIsTenantMember = !!tenant && !!activeOrgId && activeOrgId === tenant.orgId; - - const hideSidebar = pathname === "/login"; - - const sidebarItems: SidebarItem[] = [ - { icon: Home, label: "home", to: "/home", roleRequired: "anon" }, - { icon: Globe, label: "apps", to: "/apps", roleRequired: "anon" }, - ...(liveIsTenantMember - ? ([{ icon: Shield, label: "admin", to: "/admin", roleRequired: "member" }] as SidebarItem[]) - : []), - ]; - const visibleItems = filterSidebarByRole(sidebarItems, userRole); - - const isActive = (item: SidebarItem) => { - return pathname === item.to || (item.to !== "/" && pathname.startsWith(`${item.to}/`)); - }; const [betaBannerDismissed, setBetaBannerDismissed] = useState(() => { if (typeof window === "undefined") return false; @@ -134,297 +51,8 @@ function Layout() { )} -
-
- {isAuthenticated ? ( -
- - - {appName} - - - - -
- {tenant && ( - <> - {tenant.name} - / - - )} - {runtime?.accountId ?? account} - / - - {pathname === "/" ? "home" : pathname.slice(1).split("/").join(" / ")} - -
-
- ) : ( - - - {appName} - - - - )} - -
- -
-
-
- {hideSidebar && } - -
- - -
-
- -
-
-
- -
- -
- - {!isAuthenticated && !hideSidebar && ( -
- -
- )} - -
- -
+ ); } - -function MinimalHeader() { - return ( -
- -
- ); -} - -function NearBranding() { - return ( - - Built on NEAR - Built on NEAR - - ); -} - -function MobileTabBar({ - isAuthenticated, - visibleItems, - isActive, -}: { - isAuthenticated: boolean; - visibleItems: SidebarItem[]; - isActive: (item: SidebarItem) => boolean; -}) { - const [drawerOpen, setDrawerOpen] = useState(false); - const pathname = useRouterState({ select: (s) => s.location.pathname }); - const tabActive = (to: string) => (to === "/" ? pathname === "/" : pathname.startsWith(to)); - - return ( - - ); -} - -function TabItem({ - to, - icon: Icon, - label, - active, -}: { - to: string; - icon: React.ComponentType<{ className?: string }>; - label: string; - active: boolean; -}) { - return ( - - - {label} - - ); -} diff --git a/ui/src/routes/_layout/_authenticated.tsx b/ui/src/routes/_layout/_authenticated.tsx index f6c8d6de..869f23e3 100644 --- a/ui/src/routes/_layout/_authenticated.tsx +++ b/ui/src/routes/_layout/_authenticated.tsx @@ -1,5 +1,14 @@ -import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; -import { type SessionData, sessionQueryOptions } from "@/app"; +import { createFileRoute, Link, Outlet, redirect, useRouterState } from "@tanstack/react-router"; +import { ClipboardList, Compass, Globe, Home, Menu, MessageSquare, Shield } from "lucide-react"; +import { useState } from "react"; +import type { SessionData } from "@/app"; +import { getAccount, getActiveRuntime, getAppName, sessionQueryOptions } from "@/app"; +import { NearBranding } from "@/components/near-branding"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { UserNav } from "@/components/user-nav"; +import { cn } from "@/lib/utils"; interface AuthContext { isAuthenticated: boolean; @@ -11,6 +20,30 @@ interface AuthContext { isBanned: boolean; } +type SidebarRole = "anon" | "member" | "admin"; + +interface SidebarItem { + icon: React.ComponentType<{ className?: string }>; + label: string; + to: string; + roleRequired: SidebarRole; +} + +function filterSidebarByRole(items: SidebarItem[], userRole: SidebarRole): SidebarItem[] { + return items.filter((item) => { + if (item.roleRequired === "anon") return true; + if (item.roleRequired === "member" && userRole !== "anon") return true; + if (item.roleRequired === "admin" && userRole === "admin") return true; + return false; + }); +} + +function getUserRole(isAuthenticated: boolean, isAdmin: boolean): SidebarRole { + if (isAdmin) return "admin"; + if (isAuthenticated) return "member"; + return "anon"; +} + export const Route = createFileRoute("/_layout/_authenticated")({ beforeLoad: async ({ context, location }) => { const { queryClient, authClient } = context; @@ -53,9 +86,219 @@ export const Route = createFileRoute("/_layout/_authenticated")({ }); function AuthenticatedLayout() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const { runtimeConfig, session } = Route.useRouteContext(); + const appName = getAppName(runtimeConfig); + const runtime = getActiveRuntime(runtimeConfig); + const account = getAccount(runtimeConfig); + const isAdmin = session?.user?.role === "admin"; + + const sidebarItems: SidebarItem[] = [ + { icon: Home, label: "home", to: "/home", roleRequired: "anon" }, + { icon: Shield, label: "admin", to: "/admin", roleRequired: "admin" }, + { icon: MessageSquare, label: "nostr", to: "/nostr", roleRequired: "admin" }, + ]; + const visibleItems = filterSidebarByRole(sidebarItems, getUserRole(true, isAdmin)); + + const isActive = (item: SidebarItem) => { + return pathname === item.to || (item.to !== "/" && pathname.startsWith(`${item.to}/`)); + }; + return ( -
- +
+ + +
+
+
+
+ + + {appName} + + + + +
+ {runtime?.accountId ?? account} + / + + {pathname === "/" ? "home" : pathname.slice(1).split("/").join(" / ")} + +
+
+ +
+ +
+
+
+ +
+
+ +
+
+
+ +
); } + +function MobileTabBar({ + visibleItems, + isActive, +}: { + visibleItems: SidebarItem[]; + isActive: (item: SidebarItem) => boolean; +}) { + const [drawerOpen, setDrawerOpen] = useState(false); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const tabActive = (to: string) => (to === "/" ? pathname === "/" : pathname.startsWith(to)); + + return ( + + ); +} + +function TabItem({ + to, + icon: Icon, + label, + active, +}: { + to: string; + icon: React.ComponentType<{ className?: string }>; + label: string; + active: boolean; +}) { + return ( + + + {label} + + ); +} diff --git a/ui/src/routes/_layout/_authenticated/admin/index.tsx b/ui/src/routes/_layout/_authenticated/admin/index.tsx new file mode 100644 index 00000000..65c3c2d3 --- /dev/null +++ b/ui/src/routes/_layout/_authenticated/admin/index.tsx @@ -0,0 +1,104 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { Building2, Settings, Users } from "lucide-react"; +import { getAccount } from "@/app"; +import { Button, Card } from "@/components"; +import { PageContainer } from "@/components/layout/page-container"; + +export const Route = createFileRoute("/_layout/_authenticated/admin/")({ + head: () => ({ + meta: [{ title: "Admin Dashboard | app" }], + }), + component: AdminDashboard, +}); + +function AdminDashboard() { + const { auth } = Route.useRouteContext(); + const account = getAccount(); + const user = auth?.user ?? null; + + return ( + +
+
+
+
+

+ Dashboard +

+

+ Signed in as {account} +

+
+
+
+ +
+ + + + +
+ +
+

Manage

+
+ +
+ +
+

Organizations

+

+ Manage organizations, members, roles, and invitations. +

+ +
+ + +
+ +
+

Settings

+

+ Update your profile, auth methods, and security preferences. +

+ +
+
+
+
+
+ ); +} + +function StatCard({ + label, + value, + mono, +}: { + label: string; + value: React.ReactNode; + mono?: boolean; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/ui/src/routes/_layout/_authenticated/admin/system.tsx b/ui/src/routes/_layout/_authenticated/admin/system.tsx new file mode 100644 index 00000000..a98296af --- /dev/null +++ b/ui/src/routes/_layout/_authenticated/admin/system.tsx @@ -0,0 +1,73 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { getAccount, getActiveRuntime, getAppName, getRepository } from "@/app"; +import { Card } from "@/components"; +import { PageContainer } from "@/components/layout/page-container"; +import { InfoRow } from "@/components/ui/info-row"; + +export const Route = createFileRoute("/_layout/_authenticated/admin/system")({ + loader: async ({ context }) => ({ + runtimeConfig: context.runtimeConfig, + }), + head: () => ({ + meta: [{ title: "Admin System | app" }], + }), + component: AdminSystem, +}); + +function AdminSystem() { + const { runtimeConfig } = Route.useLoaderData(); + const account = getAccount(runtimeConfig); + const appName = getAppName(runtimeConfig); + const repository = getRepository(runtimeConfig); + const runtime = getActiveRuntime(runtimeConfig); + + const env = runtimeConfig?.env; + const networkId = runtimeConfig?.networkId; + const hostUrl = runtimeConfig?.hostUrl; + const apiBase = runtimeConfig?.apiBase; + const rpcBase = runtimeConfig?.rpcBase; + const assetsUrl = runtimeConfig?.assetsUrl; + const runtimeBasePath = runtime?.runtimeBasePath; + + return ( + +
+
+
+

+ System +

+

+ Runtime configuration for this deployment. +

+
+
+ +
+ +

Runtime

+ + + + +
+ + +

Deployment

+ + + + +
+ + +

Endpoints

+ + + +
+
+
+
+ ); +} diff --git a/ui/src/routes/_layout/_public.tsx b/ui/src/routes/_layout/_public.tsx new file mode 100644 index 00000000..1fd9a871 --- /dev/null +++ b/ui/src/routes/_layout/_public.tsx @@ -0,0 +1,53 @@ +import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router"; +import { getAppName } from "@/app"; +import { NearBranding } from "@/components/near-branding"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { UserNav } from "@/components/user-nav"; + +export const Route = createFileRoute("/_layout/_public")({ + component: PublicLayout, +}); + +function PublicLayout() { + const { runtimeConfig } = Route.useRouteContext(); + const appName = getAppName(runtimeConfig); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + return ( +
+
+
+ + + {appName} + + + + +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/ui/src/routes/_layout/about.tsx b/ui/src/routes/_layout/_public/about.tsx similarity index 99% rename from ui/src/routes/_layout/about.tsx rename to ui/src/routes/_layout/_public/about.tsx index ea4d4b4e..b960d03e 100644 --- a/ui/src/routes/_layout/about.tsx +++ b/ui/src/routes/_layout/_public/about.tsx @@ -41,7 +41,7 @@ async function fetchRepositoryReadme(repositoryUrl: string): Promise { const repository = getRepository(context.runtimeConfig); const description = diff --git a/ui/src/routes/_layout/_public/index.tsx b/ui/src/routes/_layout/_public/index.tsx new file mode 100644 index 00000000..8cbb4f06 --- /dev/null +++ b/ui/src/routes/_layout/_public/index.tsx @@ -0,0 +1,111 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { ArrowRight, Building2, FileCode2, Lock, Sparkles } from "lucide-react"; +import { getAccount, getActiveRuntime, getAppName, getRepository } from "@/app"; +import { Button, Card, PageContainer } from "@/components"; + +export const Route = createFileRoute("/_layout/_public/")({ + loader: async ({ context }) => ({ + runtimeConfig: context.runtimeConfig, + }), + head: () => ({ + meta: [ + { title: "Welcome | app" }, + { + name: "description", + content: "A modern starter app built with TanStack Router and Better Auth on NEAR.", + }, + ], + }), + component: LandingPage, +}); + +function LandingPage() { + const { runtimeConfig } = Route.useLoaderData(); + const appName = getAppName(runtimeConfig); + const account = getAccount(runtimeConfig); + const runtime = getActiveRuntime(runtimeConfig); + const repository = getRepository(runtimeConfig); + + const accountId = runtime?.accountId ?? account; + + return ( + +
+
+
+ + {accountId} +
+ +
+

+ {appName} +

+

+ A production-ready starter built on TanStack Router, Better Auth, and Effect — with + organization management, a guarded dashboard, and a fully typed API. +

+
+ +
+ + +
+
+ +
+ +
+ +
+

Secure authentication

+

+ NEAR wallet sign-in, email, and passkeys via Better Auth — with an authenticated + layout guard and session-aware routing. +

+
+ + +
+ +
+

Organizations

+

+ Create and manage organizations with members, roles, invitations, and API keys — all + backed by typed oRPC endpoints. +

+
+ + +
+ +
+

Typed end to end

+

+ One contract drives the API and the client — schemas, validation, and types stay in + sync across the whole stack. +

+
+
+ + {repository && ( +
+

Fork the template and make it yours.

+ +
+ )} +
+
+ ); +} diff --git a/ui/src/routes/_layout/login.tsx b/ui/src/routes/_layout/_public/login.tsx similarity index 86% rename from ui/src/routes/_layout/login.tsx rename to ui/src/routes/_layout/_public/login.tsx index 96284716..fdc771d9 100644 --- a/ui/src/routes/_layout/login.tsx +++ b/ui/src/routes/_layout/_public/login.tsx @@ -3,18 +3,15 @@ import { createFileRoute, Navigate, redirect, useNavigate } from "@tanstack/reac import { useEffect, useState } from "react"; import { toast } from "sonner"; import { getAppName, sessionQueryOptions, useAuthClient } from "@/app"; -import builtOn from "@/assets/built_on.png"; -import builtOnRev from "@/assets/built_on_rev.png"; import { BrandElement } from "@/components/brand-element"; import { Button } from "@/components/ui/button"; -import { NetworkToggle } from "@/components/ui/network-toggle"; import { UnderConstruction } from "@/components/under-construction"; type SearchParams = { redirect?: string; }; -export const Route = createFileRoute("/_layout/login")({ +export const Route = createFileRoute("/_layout/_public/login")({ ssr: false, validateSearch: (search: Record): SearchParams => ({ redirect: typeof search.redirect === "string" ? search.redirect : undefined, @@ -120,8 +117,7 @@ function LoginPage() { const isPending = nearPending || anonPending; return ( -
- +
@@ -142,7 +138,25 @@ function LoginPage() {
- -
); } diff --git a/ui/src/routes/_layout/index.tsx b/ui/src/routes/_layout/index.tsx deleted file mode 100644 index f998f7f7..00000000 --- a/ui/src/routes/_layout/index.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { createFileRoute, Link, redirect } from "@tanstack/react-router"; -import { Building2, Plus, Shield } from "lucide-react"; -import { sessionQueryOptions, useApiClient, useAuthClient } from "@/app"; -import { Button, Card } from "@/components"; -import { PageContainer } from "@/components/layout/page-container"; - -export const Route = createFileRoute("/_layout/")({ - beforeLoad: async ({ context }) => { - const { authClient } = context; - const session = await context.queryClient.ensureQueryData( - sessionQueryOptions(authClient, context.session), - ); - if (!session?.user) { - throw redirect({ to: "/login" }); - } - }, - component: TenantListPage, -}); - -function TenantListPage() { - const apiClient = useApiClient(); - const auth = useAuthClient(); - const { data: session } = useQuery(sessionQueryOptions(auth, undefined)); - - const { data: tenants = [], isLoading } = useQuery({ - queryKey: ["tenants"], - queryFn: () => apiClient.listTenants(), - staleTime: 30_000, - }); - - return ( - -
-
-
- - Tenants -
-
-
-

- {session?.user?.name || session?.user?.email || "Your"} Tenants -

-
- -
-
- - {isLoading ? ( -
- {[1, 2, 3].map((n) => ( - -
-
-
- - ))} -
- ) : tenants.length === 0 ? ( - - -

No tenants yet.

-

- Create a tenant to deploy your own app with custom UI and API. -

- -
- ) : ( -
- {tenants.map((tenant) => ( - -
-
{tenant.name}
-
- {tenant.subdomain} -
-
-
- - -
-
- -
-
- ))} -
- )} -
- - ); -} - -function TenantMeta({ label, value, mono }: { label: string; value: string; mono?: boolean }) { - return ( -
- {label} - - {value} - -
- ); -} diff --git a/ui/src/routes/_layout/skill.tsx b/ui/src/routes/_layout/skill.tsx deleted file mode 100644 index db047a71..00000000 --- a/ui/src/routes/_layout/skill.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { Check, Copy, ExternalLink, FileText } from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; -import { getAccount, getActiveRuntime, getAppName } from "@/app"; -import { PageContainer } from "@/components/layout/page-container"; -import { Button } from "@/components/ui/button"; -import { Markdown } from "@/components/ui/markdown"; - -const INTENT_REGISTRY_URL = "https://tanstack.com/intent/registry/everything-dev"; - -export const Route = createFileRoute("/_layout/skill")({ - loader: async ({ context }) => { - const runtimeConfig = context.runtimeConfig; - - const skill = await fetch("/skill.md") - .then(async (response) => { - if (!response.ok) { - throw new Error(`Failed to load skill: ${response.status}`); - } - - return response.text(); - }) - .catch(() => null); - - return { - runtimeConfig, - skill, - intentRegistryUrl: INTENT_REGISTRY_URL, - }; - }, - head: () => ({ - meta: [ - { title: "Skill | app" }, - { - name: "description", - content: "Agent-oriented instructions for running, editing, and publishing this runtime.", - }, - ], - }), - component: SkillPage, -}); - -function SkillPage() { - const { skill, runtimeConfig, intentRegistryUrl } = Route.useLoaderData(); - const runtime = getActiveRuntime(runtimeConfig); - const account = getAccount(runtimeConfig); - const appName = getAppName(runtimeConfig); - const [copied, setCopied] = useState(false); - - const accountId = runtime?.accountId ?? account; - - const handleCopy = async () => { - if (!skill) { - toast.error("Skill prompt unavailable"); - return; - } - - await navigator.clipboard.writeText(skill); - setCopied(true); - toast.success("Skill prompt copied"); - setTimeout(() => setCopied(false), 2000); - }; - - return ( - -
-
-
-
-
- -
-
-
- {accountId} - / - {appName} -
-

- Agent-ready prompt for TanStack Intent, local development, UI changes, and publish - flow. -

-
-
- -
- - - -
-
- -
- Best entry points: `npx @tanstack/intent@latest load everything-dev`, `/skill.md`, and - the registry page above. -
-
- - {skill ? ( -
- -
- ) : ( -
- -

Skill prompt unavailable.

-
- )} -
-
- ); -} From 4f8f6e907d497bfecbef26649fc00554288635b5 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 13 Aug 2026 14:20:22 -0500 Subject: [PATCH 02/12] upgrade bindings --- AGENTS.md | 2 +- LLM.txt | 2 +- README.md | 2 +- host/README.md | 16 +++++----------- host/src/services/tenant-runtime.ts | 3 ++- host/tests/integration/tenant-runtime.test.ts | 2 +- .../skills/plugin-client/SKILL.md | 2 +- .../skills/extends-config/SKILL.md | 13 +++---------- .../skills/init-upgrade/SKILL.md | 10 ++-------- .../everything-dev/skills/super-app/SKILL.md | 19 ++++++------------- 10 files changed, 23 insertions(+), 48 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8453be7f..ddca75f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,7 +168,7 @@ Current fixed-core host rules: - the shared host still boots once from one base runtime snapshot - child runtime config must extend the active BOS runtime - supported request-scoped overrides are `ui` and existing `plugins..ui` -- tenant SSR is gated by `TENANT_WHITELIST` and `ALLOW_UNTRUSTED_SSR` +- tenant SSR is gated per-tenant by the `allowSsr` column on the tenant record; the host's BindingResolver reads permissions from the API's `GET /tenants/bindings` endpoint (cached for 30s) - nested label routing and account-relative tenant derivation are the intended architecture direction, but not the complete resolver behavior today For full per-request host/plugin/auth/api swapping, start from `plans/runtime-config-hot-swap.md`. diff --git a/LLM.txt b/LLM.txt index 3b5f2820..5eb2fff3 100644 --- a/LLM.txt +++ b/LLM.txt @@ -48,7 +48,7 @@ Practical consequence: - Path-based runtime metadata works today. - Shared-host tenant UI mode now works today: the host can resolve a tenant config per request, enforce that it extends the base BOS runtime, and apply request-scoped UI overrides while keeping auth/API/plugin routers fixed. - Supported tenant overrides today are `app.ui`, existing `plugins..ui`, and existing `plugins..sidebar`. -- Tenant SSR is gated by `TENANT_WHITELIST` and `ALLOW_UNTRUSTED_SSR`. +- Tenant SSR is gated per-tenant by the `allow_ssr` column on the tenant record; the host's BindingResolver reads these permissions from the API's `GET /tenants/bindings` endpoint (cached for 30s). - True wildcard-domain full runtime swapping for host/plugin/auth/api still needs dynamic scoped app rebuilding in the host. - The active design doc for that work is `plans/runtime-config-hot-swap.md`. diff --git a/README.md b/README.md index 25b62e71..cf47c93f 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ What works today: - `app.ui` - existing `plugins..ui` - existing `plugins..sidebar` -- Tenant SSR is gated by `TENANT_WHITELIST` and `ALLOW_UNTRUSTED_SSR`. +- Tenant SSR is gated per-tenant by the `allow_ssr` column on the tenant record; the host's BindingResolver reads these permissions from the API's `GET /tenants/bindings` endpoint (cached for 30s). Design direction: - nested labels compose onto the active runtime account, such as `chicago.pizza.com -> bos://chicago.pizza.pingpayio.near/pizza.com` diff --git a/host/README.md b/host/README.md index 41c8db50..2f47fe65 100644 --- a/host/README.md +++ b/host/README.md @@ -96,9 +96,6 @@ For the temporary publish registry, use `bos publish` or `bos publish --deploy`. | `API_SOURCE` | `local` or `remote` | Based on NODE_ENV | | `API_PROXY` | Proxy API requests to another host URL | - | | `NETWORK_ID` | Tenant account suffix resolution: `mainnet` or `testnet` | `mainnet` | -| `ALLOW_OVERRIDE` | Comma-separated tenant override targets like `ui`, `plugins.*`, `plugins.apps` | - | -| `TENANT_WHITELIST` | Comma-separated tenant account IDs allowed to SSR | - | -| `ALLOW_UNTRUSTED_SSR` | Allow tenant SSR without whitelist | `false` | | `HOST_DATABASE_URL` | SQLite database URL for auth | `file:./database.db` | | `HOST_DATABASE_AUTH_TOKEN` | Auth token for remote database | - | | `BETTER_AUTH_SECRET` | Secret for session encryption | - | @@ -112,7 +109,7 @@ For the temporary publish registry, use `bos publish` or `bos publish --deploy`. - Current: tenant config must extend the base BOS runtime - Current: tenant accounts derive relative to the active runtime account namespace - Current: supported tenant overrides are `app.ui`, existing `plugins..ui`, and existing `plugins..sidebar` -- Current: tenant SSR is opt-in via `TENANT_WHITELIST` or `ALLOW_UNTRUSTED_SSR=true` +- Current: tenant SSR is gated per-tenant by the `allowSsr` column on the tenant record; the host's BindingResolver reads these permissions from the API's `GET /tenants/bindings` endpoint (cached for 30s) - Not yet implemented: tenant API/auth overrides in fixed-core mode - Not yet implemented: dynamic new plugin IDs per tenant @@ -123,9 +120,6 @@ Example deployment: ```bash BOS_ACCOUNT=linktree.near BOS_GATEWAY=linktree.com -ALLOW_OVERRIDE=ui,plugins.* -TENANT_WHITELIST=alice.linktree.near,bob.linktree.near -ALLOW_UNTRUSTED_SSR=false bos start --no-interactive ``` @@ -139,7 +133,8 @@ Example tenant behavior: Tenant config rules: - must extend the base BOS runtime -- may only override targets allowed by `ALLOW_OVERRIDE` +- may only override the tenant `ui` when `allow_ui_overrides` is true on the tenant record +- plugin UI overrides require `allow_backend_overrides` on the tenant record - in fixed-core mode, only UI-facing overrides are applied - custom UI remotes must provide integrity - custom plugin UI remotes must provide integrity @@ -147,9 +142,8 @@ Tenant config rules: Tenant SSR rules: -- if `ALLOW_UNTRUSTED_SSR=true`, any valid tenant UI with SSR config may SSR -- otherwise the tenant account must appear in `TENANT_WHITELIST` -- non-whitelisted tenants fall back to client rendering +- tenant SSR is allowed only when the tenant record has `allow_ssr` set +- tenants without `allow_ssr` fall back to client rendering ### Proxy Mode diff --git a/host/src/services/tenant-runtime.ts b/host/src/services/tenant-runtime.ts index 4f3b0cae..ca3adcf7 100644 --- a/host/src/services/tenant-runtime.ts +++ b/host/src/services/tenant-runtime.ts @@ -7,7 +7,7 @@ import { verifySriForUrl } from "everything-dev/integrity"; import type { RuntimePlugin } from "../types"; import { logger } from "../utils/logger"; import { resolveDomain } from "../utils/normalize"; -import { createBindingResolver, type BindingResolver } from "./binding-resolver"; +import { clearBindingResolverCache, createBindingResolver, type BindingResolver } from "./binding-resolver"; const REMOTE_CONFIG_TTL_MS = 30_000; const VERIFICATION_TTL_MS = 5 * 60_000; @@ -90,6 +90,7 @@ export function getTenantRuntimeErrorResponse(error: unknown): { status: number; export function clearTenantRuntimeCaches() { remoteConfigCache.clear(); verifiedUiCache.clear(); + clearBindingResolverCache(); } function getRemoteConfigCached(bosUrl: string, env: BosEnv) { diff --git a/host/tests/integration/tenant-runtime.test.ts b/host/tests/integration/tenant-runtime.test.ts index 1cbf21ec..96c536bb 100644 --- a/host/tests/integration/tenant-runtime.test.ts +++ b/host/tests/integration/tenant-runtime.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeConfig } from "../../src/services/config"; const loadRemoteConfigMock = vi.fn(); diff --git a/packages/every-plugin/skills/plugin-client/SKILL.md b/packages/every-plugin/skills/plugin-client/SKILL.md index 7c8125b8..5a9f9d30 100644 --- a/packages/every-plugin/skills/plugin-client/SKILL.md +++ b/packages/every-plugin/skills/plugin-client/SKILL.md @@ -437,7 +437,7 @@ This writes `bos.config.json` with `extends`, scaffolds the project, and generat ### What Can Be Overridden -Child configs can override `app.ui` (custom UI CDN) and `plugins.` (custom plugin URLs). The host's `ALLOW_OVERRIDE` env var controls which sections tenants can customize (`ui`, `plugins`, `plugins.`). +Child configs can override `app.ui` (custom UI CDN) and `plugins.` (custom plugin URLs). Which sections a tenant can customize is controlled per-tenant by the `allow_ui_overrides` and `allow_backend_overrides` flags on the tenant record, resolved by the host from the API's `GET /tenants/bindings` endpoint (cached for 30s). See the `extends-config` skill for deep merge semantics, per-environment extends, and canonical field ordering. diff --git a/packages/everything-dev/skills/extends-config/SKILL.md b/packages/everything-dev/skills/extends-config/SKILL.md index 65fe9041..c8fc8d0a 100644 --- a/packages/everything-dev/skills/extends-config/SKILL.md +++ b/packages/everything-dev/skills/extends-config/SKILL.md @@ -73,13 +73,7 @@ Use this mental model: - `domain` is the public ingress for this runtime - a runtime can be a child in lineage and still become a new tenant root on its own domain -With host env like: - -```bash -ALLOW_OVERRIDE=ui,plugins.* -TENANT_WHITELIST=pizza.pingpayio.near -ALLOW_UNTRUSTED_SSR=false -``` +Tenant permissions come from the DB, not env vars. The host fetches the tenant bindings map from the API's `GET /tenants/bindings` endpoint (cached for 30s), and the tenant record's allow flags gate what may be overridden and whether SSR is permitted. Design target for request mapping: - `pizza.com` -> base runtime `bos://pizza.pingpayio.near/pizza.com` @@ -101,9 +95,8 @@ The tenant config must extend the base BOS runtime. Tenant API/auth overrides an ### SSR behavior Tenant SSR is gated separately from inheritance: -- if `ALLOW_UNTRUSTED_SSR=true`, any valid tenant with SSR config may SSR -- otherwise the tenant account must be listed in `TENANT_WHITELIST` -- non-whitelisted tenants fall back to client rendering +- a tenant may SSR only when its record has `allow_ssr` set +- tenants without `allow_ssr` fall back to client rendering ## Resolved Config: `.bos/bos.resolved-config.json` diff --git a/packages/everything-dev/skills/init-upgrade/SKILL.md b/packages/everything-dev/skills/init-upgrade/SKILL.md index df40b85e..dad27afa 100644 --- a/packages/everything-dev/skills/init-upgrade/SKILL.md +++ b/packages/everything-dev/skills/init-upgrade/SKILL.md @@ -81,15 +81,9 @@ Child apps can either run as their own base runtime on their own domain, or as r Shared-host children must extend the base runtime and do not introduce new server-side plugin IDs dynamically. -### 4. Host deployment env for shared-host mode +### 4. Host resolution for shared-host mode -The shared host uses these env vars to resolve descendant requests: - -```bash -ALLOW_OVERRIDE=ui,plugins.* -TENANT_WHITELIST=pizza.pingpayio.near,chicago.pizza.pingpayio.near -ALLOW_UNTRUSTED_SSR=false -``` +The shared host resolves descendant requests via a DB-backed binding map. It fetches tenant permissions from the API's `GET /tenants/bindings` endpoint (cached for 30s) — no env vars are needed: Design target, for example: - `pingpay.io` -> base runtime `bos://pingpayio.near/pingpay.io` diff --git a/packages/everything-dev/skills/super-app/SKILL.md b/packages/everything-dev/skills/super-app/SKILL.md index 4adae2f3..bc32b536 100644 --- a/packages/everything-dev/skills/super-app/SKILL.md +++ b/packages/everything-dev/skills/super-app/SKILL.md @@ -87,20 +87,13 @@ This runtime is still a lineage child because it extends the parent, but it is a You can also override existing plugin UIs and sidebar entries for tenant-specific navigation. -## Host Env For Tenant Mode +## Host Resolution For Tenant Mode -The shared host uses these env vars to resolve tenants: +The shared host resolves tenant permissions from the API, not env vars. It fetches the tenant bindings map from the API's `GET /tenants/bindings` endpoint (cached for 30s) and applies DB-backed per-tenant permissions: -```bash -ALLOW_OVERRIDE=ui,plugins.* -TENANT_WHITELIST=pizza.pingpayio.near,chicago.pizza.pingpayio.near -ALLOW_UNTRUSTED_SSR=false -``` - -Meaning: -- `ALLOW_OVERRIDE` controls which tenant config sections can affect request-scoped composition. Format: comma-separated list (e.g., `ui,plugins.*`) where `plugins.*` is a glob matching all plugin overrides -- `TENANT_WHITELIST` controls which tenants may use SSR -- `ALLOW_UNTRUSTED_SSR=true` allows SSR for any valid tenant with SSR config +- `allow_ui_overrides` controls whether the tenant `ui` override can affect request-scoped composition +- `allow_backend_overrides` controls whether tenant plugin UI overrides (`plugins..ui`, `plugins..sidebar`) are applied +- `allow_ssr` controls whether the tenant may use SSR ## Resolution Rules @@ -141,7 +134,7 @@ If tenant overrides are not applying as expected: 1. **Verify the tenant config exists in FastKV**: The config must be published to `{tenantAccount}/bos/gateways/{gateway}/bos.config.json` 2. **Check extends chain**: The tenant config must extend the base runtime via its `extends` field 3. **Check host logs**: Run `cat .bos/logs/host.log` and look for tenant resolution messages — the host logs which runtime config it resolved for each request -4. **Check env vars**: `ALLOW_OVERRIDE` must include the sections you're overriding (e.g., `ui` or `plugins.*`) +4. **Check tenant permissions**: the tenant record must have the relevant allow flag set in the database (e.g., `allow_ui_overrides` for a `ui` override, `allow_backend_overrides` for plugin UI overrides) 5. **Verify integrity**: If tenant remote URLs have integrity hashes, the host validates them — mismatches cause rejection 6. **Missing tenant config**: If the tenant config is not found in FastKV, the host silently serves the base runtime config without tenant overrides — no error is surfaced to the browser From d944f55df0415055187c0c785c16da6cfc8c8231 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 13 Aug 2026 14:24:09 -0500 Subject: [PATCH 03/12] add branding --- .env.example | 6 +- ui/src/components/near-branding.tsx | 24 +++ ui/src/routeTree.gen.ts | 223 ++++++++++++++++++---------- 3 files changed, 168 insertions(+), 85 deletions(-) create mode 100644 ui/src/components/near-branding.tsx diff --git a/.env.example b/.env.example index 452789fd..dca67750 100644 --- a/.env.example +++ b/.env.example @@ -2,13 +2,15 @@ # Update values as needed for your local environment # app.host -CORS_ORIGIN=http://localhost:4100 +CORS_ORIGIN=http://localhost:3000 CSP_STRICT= # app.api API_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5432/api_db # app.auth +NEAR_SUB_ACCOUNT_PARENT_KEY_MAINNET= +NEAR_SUB_ACCOUNT_PARENT_KEY_TESTNET= AUTH_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5433/auth_db BETTER_AUTH_SECRET= GITHUB_CLIENT_SECRET= @@ -19,8 +21,6 @@ TWILIO_AUTH_TOKEN= TWILIO_PHONE_NUMBER= RESEND_API_KEY= NEAR_RELAYER_PRIVATE_KEY= -NEAR_SUB_ACCOUNT_PARENT_KEY_MAINNET= -NEAR_SUB_ACCOUNT_PARENT_KEY_TESTNET= # plugins.template TEMPLATE_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5434/template_db diff --git a/ui/src/components/near-branding.tsx b/ui/src/components/near-branding.tsx new file mode 100644 index 00000000..e3454dcf --- /dev/null +++ b/ui/src/components/near-branding.tsx @@ -0,0 +1,24 @@ +import builtOn from "@/assets/built_on.png"; +import builtOnRev from "@/assets/built_on_rev.png"; + +export function NearBranding() { + return ( + + Built on NEAR + Built on NEAR + + ); +} diff --git a/ui/src/routeTree.gen.ts b/ui/src/routeTree.gen.ts index 8ee2490c..c7659df3 100644 --- a/ui/src/routeTree.gen.ts +++ b/ui/src/routeTree.gen.ts @@ -10,21 +10,22 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LayoutRouteImport } from './routes/_layout' -import { Route as LayoutIndexRouteImport } from './routes/_layout/index' -import { Route as LayoutSkillRouteImport } from './routes/_layout/skill' -import { Route as LayoutLoginRouteImport } from './routes/_layout/login' -import { Route as LayoutAboutRouteImport } from './routes/_layout/about' +import { Route as LayoutPublicRouteImport } from './routes/_layout/_public' import { Route as LayoutAuthenticatedRouteImport } from './routes/_layout/_authenticated' import { Route as LayoutThingsIndexRouteImport } from './routes/_layout/things/index' import { Route as LayoutAppsIndexRouteImport } from './routes/_layout/apps/index' +import { Route as LayoutPublicIndexRouteImport } from './routes/_layout/_public/index' import { Route as LayoutThingsLiveRouteImport } from './routes/_layout/things/live' import { Route as LayoutThingsThingIdRouteImport } from './routes/_layout/things/$thingId' +import { Route as LayoutPublicLoginRouteImport } from './routes/_layout/_public/login' +import { Route as LayoutPublicAboutRouteImport } from './routes/_layout/_public/about' import { Route as LayoutAuthenticatedSettingsRouteImport } from './routes/_layout/_authenticated/settings' import { Route as LayoutAuthenticatedHomeRouteImport } from './routes/_layout/_authenticated/home' import { Route as LayoutAuthenticatedAdminRouteImport } from './routes/_layout/_authenticated/admin' import { Route as LayoutAppsAccountIdIndexRouteImport } from './routes/_layout/apps/$accountId/index' import { Route as LayoutAuthenticatedSettingsIndexRouteImport } from './routes/_layout/_authenticated/settings/index' import { Route as LayoutAuthenticatedOrganizationsIndexRouteImport } from './routes/_layout/_authenticated/organizations/index' +import { Route as LayoutAuthenticatedAdminIndexRouteImport } from './routes/_layout/_authenticated/admin/index' import { Route as LayoutAppsAccountIdGatewayIdRouteImport } from './routes/_layout/apps/$accountId/$gatewayId' import { Route as LayoutAuthenticatedThingsNewRouteImport } from './routes/_layout/_authenticated/things/new' import { Route as LayoutAuthenticatedTenantNewRouteImport } from './routes/_layout/_authenticated/tenant/new' @@ -34,30 +35,15 @@ import { Route as LayoutAuthenticatedSettingsProfileRouteImport } from './routes import { Route as LayoutAuthenticatedSettingsAuthMethodsRouteImport } from './routes/_layout/_authenticated/settings/auth-methods' import { Route as LayoutAuthenticatedOrganizationsNewRouteImport } from './routes/_layout/_authenticated/organizations/new' import { Route as LayoutAuthenticatedOrganizationsSlugRouteImport } from './routes/_layout/_authenticated/organizations/$slug' +import { Route as LayoutAuthenticatedAdminSystemRouteImport } from './routes/_layout/_authenticated/admin/system' import { Route as LayoutAuthenticatedAcceptInvitationIdRouteImport } from './routes/_layout/_authenticated/accept-invitation.$id' const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) -const LayoutIndexRoute = LayoutIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => LayoutRoute, -} as any) -const LayoutSkillRoute = LayoutSkillRouteImport.update({ - id: '/skill', - path: '/skill', - getParentRoute: () => LayoutRoute, -} as any) -const LayoutLoginRoute = LayoutLoginRouteImport.update({ - id: '/login', - path: '/login', - getParentRoute: () => LayoutRoute, -} as any) -const LayoutAboutRoute = LayoutAboutRouteImport.update({ - id: '/about', - path: '/about', +const LayoutPublicRoute = LayoutPublicRouteImport.update({ + id: '/_public', getParentRoute: () => LayoutRoute, } as any) const LayoutAuthenticatedRoute = LayoutAuthenticatedRouteImport.update({ @@ -74,6 +60,11 @@ const LayoutAppsIndexRoute = LayoutAppsIndexRouteImport.update({ path: '/apps/', getParentRoute: () => LayoutRoute, } as any) +const LayoutPublicIndexRoute = LayoutPublicIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => LayoutPublicRoute, +} as any) const LayoutThingsLiveRoute = LayoutThingsLiveRouteImport.update({ id: '/things/live', path: '/things/live', @@ -84,6 +75,16 @@ const LayoutThingsThingIdRoute = LayoutThingsThingIdRouteImport.update({ path: '/things/$thingId', getParentRoute: () => LayoutRoute, } as any) +const LayoutPublicLoginRoute = LayoutPublicLoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => LayoutPublicRoute, +} as any) +const LayoutPublicAboutRoute = LayoutPublicAboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => LayoutPublicRoute, +} as any) const LayoutAuthenticatedSettingsRoute = LayoutAuthenticatedSettingsRouteImport.update({ id: '/settings', @@ -119,6 +120,12 @@ const LayoutAuthenticatedOrganizationsIndexRoute = path: '/organizations/', getParentRoute: () => LayoutAuthenticatedRoute, } as any) +const LayoutAuthenticatedAdminIndexRoute = + LayoutAuthenticatedAdminIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => LayoutAuthenticatedAdminRoute, + } as any) const LayoutAppsAccountIdGatewayIdRoute = LayoutAppsAccountIdGatewayIdRouteImport.update({ id: '/apps/$accountId/$gatewayId', @@ -173,6 +180,12 @@ const LayoutAuthenticatedOrganizationsSlugRoute = path: '/organizations/$slug', getParentRoute: () => LayoutAuthenticatedRoute, } as any) +const LayoutAuthenticatedAdminSystemRoute = + LayoutAuthenticatedAdminSystemRouteImport.update({ + id: '/system', + path: '/system', + getParentRoute: () => LayoutAuthenticatedAdminRoute, + } as any) const LayoutAuthenticatedAcceptInvitationIdRoute = LayoutAuthenticatedAcceptInvitationIdRouteImport.update({ id: '/accept-invitation/$id', @@ -181,18 +194,18 @@ const LayoutAuthenticatedAcceptInvitationIdRoute = } as any) export interface FileRoutesByFullPath { - '/': typeof LayoutIndexRoute - '/about': typeof LayoutAboutRoute - '/login': typeof LayoutLoginRoute - '/skill': typeof LayoutSkillRoute - '/admin': typeof LayoutAuthenticatedAdminRoute + '/': typeof LayoutPublicIndexRoute + '/admin': typeof LayoutAuthenticatedAdminRouteWithChildren '/home': typeof LayoutAuthenticatedHomeRoute '/settings': typeof LayoutAuthenticatedSettingsRouteWithChildren + '/about': typeof LayoutPublicAboutRoute + '/login': typeof LayoutPublicLoginRoute '/things/$thingId': typeof LayoutThingsThingIdRoute '/things/live': typeof LayoutThingsLiveRoute '/apps/': typeof LayoutAppsIndexRoute '/things/': typeof LayoutThingsIndexRoute '/accept-invitation/$id': typeof LayoutAuthenticatedAcceptInvitationIdRoute + '/admin/system': typeof LayoutAuthenticatedAdminSystemRoute '/organizations/$slug': typeof LayoutAuthenticatedOrganizationsSlugRoute '/organizations/new': typeof LayoutAuthenticatedOrganizationsNewRoute '/settings/auth-methods': typeof LayoutAuthenticatedSettingsAuthMethodsRoute @@ -202,22 +215,22 @@ export interface FileRoutesByFullPath { '/tenant/new': typeof LayoutAuthenticatedTenantNewRoute '/things/new': typeof LayoutAuthenticatedThingsNewRoute '/apps/$accountId/$gatewayId': typeof LayoutAppsAccountIdGatewayIdRoute + '/admin/': typeof LayoutAuthenticatedAdminIndexRoute '/organizations/': typeof LayoutAuthenticatedOrganizationsIndexRoute '/settings/': typeof LayoutAuthenticatedSettingsIndexRoute '/apps/$accountId/': typeof LayoutAppsAccountIdIndexRoute } export interface FileRoutesByTo { - '/': typeof LayoutIndexRoute - '/about': typeof LayoutAboutRoute - '/login': typeof LayoutLoginRoute - '/skill': typeof LayoutSkillRoute - '/admin': typeof LayoutAuthenticatedAdminRoute + '/': typeof LayoutPublicIndexRoute '/home': typeof LayoutAuthenticatedHomeRoute + '/about': typeof LayoutPublicAboutRoute + '/login': typeof LayoutPublicLoginRoute '/things/$thingId': typeof LayoutThingsThingIdRoute '/things/live': typeof LayoutThingsLiveRoute '/apps': typeof LayoutAppsIndexRoute '/things': typeof LayoutThingsIndexRoute '/accept-invitation/$id': typeof LayoutAuthenticatedAcceptInvitationIdRoute + '/admin/system': typeof LayoutAuthenticatedAdminSystemRoute '/organizations/$slug': typeof LayoutAuthenticatedOrganizationsSlugRoute '/organizations/new': typeof LayoutAuthenticatedOrganizationsNewRoute '/settings/auth-methods': typeof LayoutAuthenticatedSettingsAuthMethodsRoute @@ -227,6 +240,7 @@ export interface FileRoutesByTo { '/tenant/new': typeof LayoutAuthenticatedTenantNewRoute '/things/new': typeof LayoutAuthenticatedThingsNewRoute '/apps/$accountId/$gatewayId': typeof LayoutAppsAccountIdGatewayIdRoute + '/admin': typeof LayoutAuthenticatedAdminIndexRoute '/organizations': typeof LayoutAuthenticatedOrganizationsIndexRoute '/settings': typeof LayoutAuthenticatedSettingsIndexRoute '/apps/$accountId': typeof LayoutAppsAccountIdIndexRoute @@ -235,18 +249,19 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/_layout': typeof LayoutRouteWithChildren '/_layout/_authenticated': typeof LayoutAuthenticatedRouteWithChildren - '/_layout/about': typeof LayoutAboutRoute - '/_layout/login': typeof LayoutLoginRoute - '/_layout/skill': typeof LayoutSkillRoute - '/_layout/': typeof LayoutIndexRoute - '/_layout/_authenticated/admin': typeof LayoutAuthenticatedAdminRoute + '/_layout/_public': typeof LayoutPublicRouteWithChildren + '/_layout/_authenticated/admin': typeof LayoutAuthenticatedAdminRouteWithChildren '/_layout/_authenticated/home': typeof LayoutAuthenticatedHomeRoute '/_layout/_authenticated/settings': typeof LayoutAuthenticatedSettingsRouteWithChildren + '/_layout/_public/about': typeof LayoutPublicAboutRoute + '/_layout/_public/login': typeof LayoutPublicLoginRoute '/_layout/things/$thingId': typeof LayoutThingsThingIdRoute '/_layout/things/live': typeof LayoutThingsLiveRoute + '/_layout/_public/': typeof LayoutPublicIndexRoute '/_layout/apps/': typeof LayoutAppsIndexRoute '/_layout/things/': typeof LayoutThingsIndexRoute '/_layout/_authenticated/accept-invitation/$id': typeof LayoutAuthenticatedAcceptInvitationIdRoute + '/_layout/_authenticated/admin/system': typeof LayoutAuthenticatedAdminSystemRoute '/_layout/_authenticated/organizations/$slug': typeof LayoutAuthenticatedOrganizationsSlugRoute '/_layout/_authenticated/organizations/new': typeof LayoutAuthenticatedOrganizationsNewRoute '/_layout/_authenticated/settings/auth-methods': typeof LayoutAuthenticatedSettingsAuthMethodsRoute @@ -256,6 +271,7 @@ export interface FileRoutesById { '/_layout/_authenticated/tenant/new': typeof LayoutAuthenticatedTenantNewRoute '/_layout/_authenticated/things/new': typeof LayoutAuthenticatedThingsNewRoute '/_layout/apps/$accountId/$gatewayId': typeof LayoutAppsAccountIdGatewayIdRoute + '/_layout/_authenticated/admin/': typeof LayoutAuthenticatedAdminIndexRoute '/_layout/_authenticated/organizations/': typeof LayoutAuthenticatedOrganizationsIndexRoute '/_layout/_authenticated/settings/': typeof LayoutAuthenticatedSettingsIndexRoute '/_layout/apps/$accountId/': typeof LayoutAppsAccountIdIndexRoute @@ -264,17 +280,17 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' - | '/about' - | '/login' - | '/skill' | '/admin' | '/home' | '/settings' + | '/about' + | '/login' | '/things/$thingId' | '/things/live' | '/apps/' | '/things/' | '/accept-invitation/$id' + | '/admin/system' | '/organizations/$slug' | '/organizations/new' | '/settings/auth-methods' @@ -284,22 +300,22 @@ export interface FileRouteTypes { | '/tenant/new' | '/things/new' | '/apps/$accountId/$gatewayId' + | '/admin/' | '/organizations/' | '/settings/' | '/apps/$accountId/' fileRoutesByTo: FileRoutesByTo to: | '/' + | '/home' | '/about' | '/login' - | '/skill' - | '/admin' - | '/home' | '/things/$thingId' | '/things/live' | '/apps' | '/things' | '/accept-invitation/$id' + | '/admin/system' | '/organizations/$slug' | '/organizations/new' | '/settings/auth-methods' @@ -309,6 +325,7 @@ export interface FileRouteTypes { | '/tenant/new' | '/things/new' | '/apps/$accountId/$gatewayId' + | '/admin' | '/organizations' | '/settings' | '/apps/$accountId' @@ -316,18 +333,19 @@ export interface FileRouteTypes { | '__root__' | '/_layout' | '/_layout/_authenticated' - | '/_layout/about' - | '/_layout/login' - | '/_layout/skill' - | '/_layout/' + | '/_layout/_public' | '/_layout/_authenticated/admin' | '/_layout/_authenticated/home' | '/_layout/_authenticated/settings' + | '/_layout/_public/about' + | '/_layout/_public/login' | '/_layout/things/$thingId' | '/_layout/things/live' + | '/_layout/_public/' | '/_layout/apps/' | '/_layout/things/' | '/_layout/_authenticated/accept-invitation/$id' + | '/_layout/_authenticated/admin/system' | '/_layout/_authenticated/organizations/$slug' | '/_layout/_authenticated/organizations/new' | '/_layout/_authenticated/settings/auth-methods' @@ -337,6 +355,7 @@ export interface FileRouteTypes { | '/_layout/_authenticated/tenant/new' | '/_layout/_authenticated/things/new' | '/_layout/apps/$accountId/$gatewayId' + | '/_layout/_authenticated/admin/' | '/_layout/_authenticated/organizations/' | '/_layout/_authenticated/settings/' | '/_layout/apps/$accountId/' @@ -355,32 +374,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } - '/_layout/': { - id: '/_layout/' - path: '/' + '/_layout/_public': { + id: '/_layout/_public' + path: '' fullPath: '/' - preLoaderRoute: typeof LayoutIndexRouteImport - parentRoute: typeof LayoutRoute - } - '/_layout/skill': { - id: '/_layout/skill' - path: '/skill' - fullPath: '/skill' - preLoaderRoute: typeof LayoutSkillRouteImport - parentRoute: typeof LayoutRoute - } - '/_layout/login': { - id: '/_layout/login' - path: '/login' - fullPath: '/login' - preLoaderRoute: typeof LayoutLoginRouteImport - parentRoute: typeof LayoutRoute - } - '/_layout/about': { - id: '/_layout/about' - path: '/about' - fullPath: '/about' - preLoaderRoute: typeof LayoutAboutRouteImport + preLoaderRoute: typeof LayoutPublicRouteImport parentRoute: typeof LayoutRoute } '/_layout/_authenticated': { @@ -404,6 +402,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAppsIndexRouteImport parentRoute: typeof LayoutRoute } + '/_layout/_public/': { + id: '/_layout/_public/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof LayoutPublicIndexRouteImport + parentRoute: typeof LayoutPublicRoute + } '/_layout/things/live': { id: '/_layout/things/live' path: '/things/live' @@ -418,6 +423,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutThingsThingIdRouteImport parentRoute: typeof LayoutRoute } + '/_layout/_public/login': { + id: '/_layout/_public/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LayoutPublicLoginRouteImport + parentRoute: typeof LayoutPublicRoute + } + '/_layout/_public/about': { + id: '/_layout/_public/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof LayoutPublicAboutRouteImport + parentRoute: typeof LayoutPublicRoute + } '/_layout/_authenticated/settings': { id: '/_layout/_authenticated/settings' path: '/settings' @@ -460,6 +479,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedOrganizationsIndexRouteImport parentRoute: typeof LayoutAuthenticatedRoute } + '/_layout/_authenticated/admin/': { + id: '/_layout/_authenticated/admin/' + path: '/' + fullPath: '/admin/' + preLoaderRoute: typeof LayoutAuthenticatedAdminIndexRouteImport + parentRoute: typeof LayoutAuthenticatedAdminRoute + } '/_layout/apps/$accountId/$gatewayId': { id: '/_layout/apps/$accountId/$gatewayId' path: '/apps/$accountId/$gatewayId' @@ -523,6 +549,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedOrganizationsSlugRouteImport parentRoute: typeof LayoutAuthenticatedRoute } + '/_layout/_authenticated/admin/system': { + id: '/_layout/_authenticated/admin/system' + path: '/system' + fullPath: '/admin/system' + preLoaderRoute: typeof LayoutAuthenticatedAdminSystemRouteImport + parentRoute: typeof LayoutAuthenticatedAdminRoute + } '/_layout/_authenticated/accept-invitation/$id': { id: '/_layout/_authenticated/accept-invitation/$id' path: '/accept-invitation/$id' @@ -533,6 +566,22 @@ declare module '@tanstack/react-router' { } } +interface LayoutAuthenticatedAdminRouteChildren { + LayoutAuthenticatedAdminSystemRoute: typeof LayoutAuthenticatedAdminSystemRoute + LayoutAuthenticatedAdminIndexRoute: typeof LayoutAuthenticatedAdminIndexRoute +} + +const LayoutAuthenticatedAdminRouteChildren: LayoutAuthenticatedAdminRouteChildren = + { + LayoutAuthenticatedAdminSystemRoute: LayoutAuthenticatedAdminSystemRoute, + LayoutAuthenticatedAdminIndexRoute: LayoutAuthenticatedAdminIndexRoute, + } + +const LayoutAuthenticatedAdminRouteWithChildren = + LayoutAuthenticatedAdminRoute._addFileChildren( + LayoutAuthenticatedAdminRouteChildren, + ) + interface LayoutAuthenticatedSettingsRouteChildren { LayoutAuthenticatedSettingsAuthMethodsRoute: typeof LayoutAuthenticatedSettingsAuthMethodsRoute LayoutAuthenticatedSettingsProfileRoute: typeof LayoutAuthenticatedSettingsProfileRoute @@ -558,7 +607,7 @@ const LayoutAuthenticatedSettingsRouteWithChildren = ) interface LayoutAuthenticatedRouteChildren { - LayoutAuthenticatedAdminRoute: typeof LayoutAuthenticatedAdminRoute + LayoutAuthenticatedAdminRoute: typeof LayoutAuthenticatedAdminRouteWithChildren LayoutAuthenticatedHomeRoute: typeof LayoutAuthenticatedHomeRoute LayoutAuthenticatedSettingsRoute: typeof LayoutAuthenticatedSettingsRouteWithChildren LayoutAuthenticatedAcceptInvitationIdRoute: typeof LayoutAuthenticatedAcceptInvitationIdRoute @@ -571,7 +620,7 @@ interface LayoutAuthenticatedRouteChildren { } const LayoutAuthenticatedRouteChildren: LayoutAuthenticatedRouteChildren = { - LayoutAuthenticatedAdminRoute: LayoutAuthenticatedAdminRoute, + LayoutAuthenticatedAdminRoute: LayoutAuthenticatedAdminRouteWithChildren, LayoutAuthenticatedHomeRoute: LayoutAuthenticatedHomeRoute, LayoutAuthenticatedSettingsRoute: LayoutAuthenticatedSettingsRouteWithChildren, @@ -592,12 +641,25 @@ const LayoutAuthenticatedRouteChildren: LayoutAuthenticatedRouteChildren = { const LayoutAuthenticatedRouteWithChildren = LayoutAuthenticatedRoute._addFileChildren(LayoutAuthenticatedRouteChildren) +interface LayoutPublicRouteChildren { + LayoutPublicAboutRoute: typeof LayoutPublicAboutRoute + LayoutPublicLoginRoute: typeof LayoutPublicLoginRoute + LayoutPublicIndexRoute: typeof LayoutPublicIndexRoute +} + +const LayoutPublicRouteChildren: LayoutPublicRouteChildren = { + LayoutPublicAboutRoute: LayoutPublicAboutRoute, + LayoutPublicLoginRoute: LayoutPublicLoginRoute, + LayoutPublicIndexRoute: LayoutPublicIndexRoute, +} + +const LayoutPublicRouteWithChildren = LayoutPublicRoute._addFileChildren( + LayoutPublicRouteChildren, +) + interface LayoutRouteChildren { LayoutAuthenticatedRoute: typeof LayoutAuthenticatedRouteWithChildren - LayoutAboutRoute: typeof LayoutAboutRoute - LayoutLoginRoute: typeof LayoutLoginRoute - LayoutSkillRoute: typeof LayoutSkillRoute - LayoutIndexRoute: typeof LayoutIndexRoute + LayoutPublicRoute: typeof LayoutPublicRouteWithChildren LayoutThingsThingIdRoute: typeof LayoutThingsThingIdRoute LayoutThingsLiveRoute: typeof LayoutThingsLiveRoute LayoutAppsIndexRoute: typeof LayoutAppsIndexRoute @@ -608,10 +670,7 @@ interface LayoutRouteChildren { const LayoutRouteChildren: LayoutRouteChildren = { LayoutAuthenticatedRoute: LayoutAuthenticatedRouteWithChildren, - LayoutAboutRoute: LayoutAboutRoute, - LayoutLoginRoute: LayoutLoginRoute, - LayoutSkillRoute: LayoutSkillRoute, - LayoutIndexRoute: LayoutIndexRoute, + LayoutPublicRoute: LayoutPublicRouteWithChildren, LayoutThingsThingIdRoute: LayoutThingsThingIdRoute, LayoutThingsLiveRoute: LayoutThingsLiveRoute, LayoutAppsIndexRoute: LayoutAppsIndexRoute, From e1a050495936b14d91109fed6591c375a21f067f Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 13 Aug 2026 17:23:21 -0500 Subject: [PATCH 04/12] working --- .env.example | 2 +- api/src/contract.ts | 2 +- api/src/db/migrations/0003_brief_freak.sql | 1 + api/src/db/migrations/meta/0002_snapshot.json | 21 +- api/src/db/migrations/meta/0003_snapshot.json | 165 +++++++++++++++ api/src/db/migrations/meta/_journal.json | 9 +- api/src/db/schema.ts | 12 +- api/src/index.ts | 15 +- api/src/services/tenants.ts | 16 +- host/src/services/binding-resolver.ts | 13 +- host/src/services/tenant-runtime.ts | 12 +- .../integration/ssr-bundled-runtime.test.ts | 2 +- host/tests/integration/tenant-runtime.test.ts | 45 ++-- package.json | 2 +- .../skills/api-and-auth/SKILL.md | 48 +++++ packages/everything-dev/src/infra/planner.ts | 3 + packages/everything-dev/src/ui/router.ts | 51 ++--- plugins/_template/src/db/schema.ts | 18 ++ .../regression/browser/helpers/page-ready.ts | 18 ++ tests/regression/browser/helpers/seeded.ts | 2 +- .../browser/specs/auth-redirect.spec.ts | 4 +- tests/regression/browser/specs/logout.spec.ts | 12 +- tests/regression/browser/specs/theme.spec.ts | 15 +- tests/regression/http/metadata_test.go | 28 +++ ui/rsbuild.config.ts | 2 +- ui/src/components/index.ts | 1 + ui/src/components/user-nav.tsx | 82 ++++++-- ui/src/lib/near-profile.ts | 17 ++ ui/src/routeTree.gen.ts | 42 ++++ ui/src/routes/__root.tsx | 3 +- ui/src/routes/_layout.tsx | 5 +- ui/src/routes/_layout/_authenticated/home.tsx | 19 +- ui/src/routes/_layout/_public/$accountId.tsx | 197 ++++++++++++++++++ ui/src/routes/_layout/_public/skill.tsx | 126 +++++++++++ 34 files changed, 857 insertions(+), 153 deletions(-) create mode 100644 api/src/db/migrations/0003_brief_freak.sql create mode 100644 api/src/db/migrations/meta/0003_snapshot.json create mode 100644 ui/src/lib/near-profile.ts create mode 100644 ui/src/routes/_layout/_public/$accountId.tsx create mode 100644 ui/src/routes/_layout/_public/skill.tsx diff --git a/.env.example b/.env.example index dca67750..e9e9b5e3 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ # Update values as needed for your local environment # app.host -CORS_ORIGIN=http://localhost:3000 +CORS_ORIGIN=http://localhost:4100 CSP_STRICT= # app.api diff --git a/api/src/contract.ts b/api/src/contract.ts index 92bc79b2..3c9cd6e8 100644 --- a/api/src/contract.ts +++ b/api/src/contract.ts @@ -17,7 +17,7 @@ export const TenantSchema = z.object({ id: z.string(), subdomain: z.string(), accountId: z.string(), - orgId: z.string(), + orgId: z.string().nullable(), name: z.string(), status: TenantStatusSchema, allowUiOverrides: z.boolean(), diff --git a/api/src/db/migrations/0003_brief_freak.sql b/api/src/db/migrations/0003_brief_freak.sql new file mode 100644 index 00000000..1a294453 --- /dev/null +++ b/api/src/db/migrations/0003_brief_freak.sql @@ -0,0 +1 @@ +ALTER TABLE "tenants" ALTER COLUMN "org_id" DROP NOT NULL; \ No newline at end of file diff --git a/api/src/db/migrations/meta/0002_snapshot.json b/api/src/db/migrations/meta/0002_snapshot.json index fda7cde0..4351ee45 100644 --- a/api/src/db/migrations/meta/0002_snapshot.json +++ b/api/src/db/migrations/meta/0002_snapshot.json @@ -127,23 +127,17 @@ "tenants_subdomain_unique": { "name": "tenants_subdomain_unique", "nullsNotDistinct": false, - "columns": [ - "subdomain" - ] + "columns": ["subdomain"] }, "tenants_account_id_unique": { "name": "tenants_account_id_unique", "nullsNotDistinct": false, - "columns": [ - "account_id" - ] + "columns": ["account_id"] }, "tenants_org_id_unique": { "name": "tenants_org_id_unique", "nullsNotDistinct": false, - "columns": [ - "org_id" - ] + "columns": ["org_id"] } }, "policies": {}, @@ -155,12 +149,7 @@ "public.tenant_status": { "name": "tenant_status", "schema": "public", - "values": [ - "active", - "pending", - "suspended", - "pending_deletion" - ] + "values": ["active", "pending", "suspended", "pending_deletion"] } }, "schemas": {}, @@ -173,4 +162,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/api/src/db/migrations/meta/0003_snapshot.json b/api/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 00000000..b013ab2b --- /dev/null +++ b/api/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,165 @@ +{ + "id": "85c9d130-a4b6-4026-a2df-890fddfa7518", + "prevId": "7b8a421a-d0c7-414a-96e8-0c259935178b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subdomain": { + "name": "subdomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "tenant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "allow_ui_overrides": { + "name": "allow_ui_overrides", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_backend_overrides": { + "name": "allow_backend_overrides", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_ssr": { + "name": "allow_ssr", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tenants_subdomain_idx": { + "name": "tenants_subdomain_idx", + "columns": [ + { + "expression": "subdomain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tenants_account_id_idx": { + "name": "tenants_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenants_subdomain_unique": { + "name": "tenants_subdomain_unique", + "nullsNotDistinct": false, + "columns": ["subdomain"] + }, + "tenants_account_id_unique": { + "name": "tenants_account_id_unique", + "nullsNotDistinct": false, + "columns": ["account_id"] + }, + "tenants_org_id_unique": { + "name": "tenants_org_id_unique", + "nullsNotDistinct": false, + "columns": ["org_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.tenant_status": { + "name": "tenant_status", + "schema": "public", + "values": ["active", "pending", "suspended", "pending_deletion"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/src/db/migrations/meta/_journal.json b/api/src/db/migrations/meta/_journal.json index 9e5e42bb..74597e55 100644 --- a/api/src/db/migrations/meta/_journal.json +++ b/api/src/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1786645659094, "tag": "0002_awesome_madame_web", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1786653436589, + "tag": "0003_brief_freak", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index c689380d..24aedf51 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -1,12 +1,4 @@ -import { - boolean, - pgEnum, - pgTable, - text, - timestamp, - uniqueIndex, - uuid, -} from "drizzle-orm/pg-core"; +import { boolean, pgEnum, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; export const tenantStatus = pgEnum("tenant_status", [ "active", @@ -21,7 +13,7 @@ export const tenants = pgTable( id: uuid("id").defaultRandom().primaryKey(), subdomain: text("subdomain").notNull().unique(), accountId: text("account_id").notNull().unique(), - orgId: text("org_id").notNull().unique(), + orgId: text("org_id").unique(), name: text("name").notNull(), status: tenantStatus("status").default("active").notNull(), allowUiOverrides: boolean("allow_ui_overrides").default(true).notNull(), diff --git a/api/src/index.ts b/api/src/index.ts index 9ebbaed8..cf2d04d0 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -89,7 +89,10 @@ export default createPlugin.withPlugins()({ const authorizedTenant = async ( input: { tenantId: string }, - context: { organization: { activeOrganizationId: string } }, + context: { + organization: { activeOrganizationId: string }; + near?: { primaryAccountId: string | null }; + }, ) => { const activeOrgId = context.organization.activeOrganizationId; const tenant = await services.tenants.resolveTenantById(input.tenantId); @@ -99,6 +102,16 @@ export default createPlugin.withPlugins()({ data: { resource: "tenant", resourceId: input.tenantId }, }); } + if (tenant.orgId === null) { + const isOwner = + !!context.near?.primaryAccountId && context.near.primaryAccountId === tenant.accountId; + if (!isOwner) { + throw new ORPCError("FORBIDDEN", { + message: "You do not own this personal tenant", + }); + } + return tenant; + } if (tenant.orgId !== activeOrgId) { throw new ORPCError("FORBIDDEN", { message: "You are not a member of this tenant's organization", diff --git a/api/src/services/tenants.ts b/api/src/services/tenants.ts index 4cdb1aa5..34f6231b 100644 --- a/api/src/services/tenants.ts +++ b/api/src/services/tenants.ts @@ -10,7 +10,7 @@ export interface TenantRecord { id: string; subdomain: string; accountId: string; - orgId: string; + orgId: string | null; name: string; status: TenantStatus; allowUiOverrides: boolean; @@ -34,7 +34,7 @@ export interface TenantInput { subdomain: string; name: string; accountId: string; - orgId: string; + orgId: string | null; status?: TenantStatus; allowUiOverrides?: boolean; allowBackendOverrides?: boolean; @@ -50,7 +50,13 @@ export interface TenantsService { input: Partial< Pick< TenantInput, - "name" | "subdomain" | "accountId" | "status" | "allowUiOverrides" | "allowBackendOverrides" | "allowSsr" + | "name" + | "subdomain" + | "accountId" + | "status" + | "allowUiOverrides" + | "allowBackendOverrides" + | "allowSsr" > >, ): Promise; @@ -143,7 +149,9 @@ export const TenantsLive = Layer.effect( accountId: input.accountId, orgId: input.orgId, ...(input.status !== undefined && { status: input.status }), - ...(input.allowUiOverrides !== undefined && { allowUiOverrides: input.allowUiOverrides }), + ...(input.allowUiOverrides !== undefined && { + allowUiOverrides: input.allowUiOverrides, + }), ...(input.allowBackendOverrides !== undefined && { allowBackendOverrides: input.allowBackendOverrides, }), diff --git a/host/src/services/binding-resolver.ts b/host/src/services/binding-resolver.ts index 30ed5ee4..0a97472d 100644 --- a/host/src/services/binding-resolver.ts +++ b/host/src/services/binding-resolver.ts @@ -30,11 +30,7 @@ export function clearBindingResolverCache() { function isBaseHost(hostname: string, gatewayId: string): boolean { const normalized = hostname.toLowerCase(); - return ( - normalized === gatewayId || - normalized === "localhost" || - normalized === "127.0.0.1" - ); + return normalized === gatewayId || normalized === "localhost" || normalized === "127.0.0.1"; } async function fetchBindingsFromApi(apiUrl: string): Promise { @@ -69,9 +65,7 @@ function mapBindingsByHostname( return entries; } -function ensureBindingsLoaded( - config: RuntimeConfig, -): Promise> { +function ensureBindingsLoaded(config: RuntimeConfig): Promise> { const apiUrl = config.api?.url; if (!apiUrl) { return Promise.resolve(new Map()); @@ -91,8 +85,7 @@ function ensureBindingsLoaded( return bindingsCache.refetching; } - const staleEntries = - bindingsCache?.apiUrl === apiUrl ? bindingsCache.entries : null; + const staleEntries = bindingsCache?.apiUrl === apiUrl ? bindingsCache.entries : null; const fetchPromise = fetchBindingsFromApi(apiUrl) .then((bindings) => { diff --git a/host/src/services/tenant-runtime.ts b/host/src/services/tenant-runtime.ts index ca3adcf7..69c1aa2a 100644 --- a/host/src/services/tenant-runtime.ts +++ b/host/src/services/tenant-runtime.ts @@ -1,13 +1,13 @@ -import { - buildRuntimeConfig, - loadRemoteConfig, - type RuntimeConfig, -} from "everything-dev/config"; +import { buildRuntimeConfig, loadRemoteConfig, type RuntimeConfig } from "everything-dev/config"; import { verifySriForUrl } from "everything-dev/integrity"; import type { RuntimePlugin } from "../types"; import { logger } from "../utils/logger"; import { resolveDomain } from "../utils/normalize"; -import { clearBindingResolverCache, createBindingResolver, type BindingResolver } from "./binding-resolver"; +import { + type BindingResolver, + clearBindingResolverCache, + createBindingResolver, +} from "./binding-resolver"; const REMOTE_CONFIG_TTL_MS = 30_000; const VERIFICATION_TTL_MS = 5 * 60_000; diff --git a/host/tests/integration/ssr-bundled-runtime.test.ts b/host/tests/integration/ssr-bundled-runtime.test.ts index d26b3177..c880184a 100644 --- a/host/tests/integration/ssr-bundled-runtime.test.ts +++ b/host/tests/integration/ssr-bundled-runtime.test.ts @@ -57,7 +57,7 @@ describe("bundled host SSR runtime", () => { const html = await response.text(); expect(response.status).toBe(200); - expect(html).toContain("connect to everything"); + expect(html).toContain("everything.dev"); expect(html).not.toContain("SSR unavailable, showing client app."); expect(html).not.toContain("

Loading...

"); diff --git a/host/tests/integration/tenant-runtime.test.ts b/host/tests/integration/tenant-runtime.test.ts index 96c536bb..1831a817 100644 --- a/host/tests/integration/tenant-runtime.test.ts +++ b/host/tests/integration/tenant-runtime.test.ts @@ -19,6 +19,7 @@ vi.mock("everything-dev/integrity", () => ({ const { clearTenantRuntimeCaches, resolveRequestRuntime } = await import( "../../src/services/tenant-runtime" ); + import type { BindingResolver } from "../../src/services/binding-resolver"; function createDeferred() { @@ -121,11 +122,9 @@ describe("resolveRequestRuntime", () => { it("returns the base runtime on the bare domain", async () => { const baseConfig = createBaseRuntimeConfig(); - const result = await resolveRequestRuntime( - baseConfig, - new Request("https://linktree.com/"), - { bindingResolver: createMockBindingResolver() }, - ); + const result = await resolveRequestRuntime(baseConfig, new Request("https://linktree.com/"), { + bindingResolver: createMockBindingResolver(), + }); expect(result.config).toBe(baseConfig); expect(result.tenantAccountId).toBeNull(); @@ -677,17 +676,13 @@ describe("resolveRequestRuntime", () => { }, }); - await resolveRequestRuntime( - baseConfig, - new Request("https://alice.linktree.com/"), - { - bindingResolver: createMockBindingResolver({ - hostname: "alice.linktree.com", - allowUiOverrides: true, - allowSsr: true, - }), - }, - ); + await resolveRequestRuntime(baseConfig, new Request("https://alice.linktree.com/"), { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }); const refresh = createDeferred(); verifySriForUrlMock.mockImplementationOnce(() => refresh.promise); @@ -762,17 +757,13 @@ describe("resolveRequestRuntime", () => { }, }); - await resolveRequestRuntime( - baseConfig, - new Request("https://alice.linktree.com/"), - { - bindingResolver: createMockBindingResolver({ - hostname: "alice.linktree.com", - allowUiOverrides: true, - allowSsr: true, - }), - }, - ); + await resolveRequestRuntime(baseConfig, new Request("https://alice.linktree.com/"), { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }); const refresh = createDeferred(); verifySriForUrlMock.mockImplementationOnce(() => refresh.promise); diff --git a/package.json b/package.json index 073c58bc..1f52d071 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "test:api": "cd api && bun run test tests/integration/ tests/unit/", "test:integration": "cd api && bun run test tests/integration/", "test:e2e": "bun run --cwd host test:e2e", - "regression:start:dev": "BOS_NO_PERSIST_PORTS=1 bun packages/everything-dev/src/cli.ts dev --no-interactive --port 4100 --api-port 4101 --auth-port 4102 --ui-port 4103 --plugin-port-start 4110", + "regression:start:dev": "BOS_NO_PERSIST_PORTS=1 bun packages/everything-dev/src/cli.ts dev --no-interactive --ssr --port 4100 --api-port 4101 --auth-port 4102 --ui-port 4103 --plugin-port-start 4110", "regression:start:prod": "BOS_NO_PERSIST_PORTS=1 PORT=4100 bun ./node_modules/everything-dev/dist/cli.mjs start --no-interactive", "test:regression:http:dev": "REGRESSION_MODE=dev sh -lc 'cd tests/regression/http && go test ./... -count=1 -v -timeout 10m'", "test:regression:http:prod": "REGRESSION_MODE=prod sh -lc 'cd tests/regression/http && go test ./... -count=1 -v -timeout 10m'", diff --git a/packages/everything-dev/skills/api-and-auth/SKILL.md b/packages/everything-dev/skills/api-and-auth/SKILL.md index 06416252..e302ff66 100644 --- a/packages/everything-dev/skills/api-and-auth/SKILL.md +++ b/packages/everything-dev/skills/api-and-auth/SKILL.md @@ -356,6 +356,54 @@ export function createPluginsClient(result, context) { } ``` +### Tenant-Scoped Data + +A **tenant** is a deployment record (subdomain + NEAR account + UI/backend/SSR override +permissions) — distinct from an organization (a group of users) and from a user's own data. +If your plugin stores data that belongs to a specific tenant deployment (not just a user or +org), resolve the tenant and scope every query to it. + +The `api` plugin owns the `tenants` table and exposes public lookup routes (no auth required — +tenant lookups are read-only and safe to expose): + +```ts +// From any plugin, via pluginsClient.api (injected through withPlugins()) +const tenant = context.organization?.activeOrganizationId + ? await plugins.api().resolveTenantByOrgId({ orgId: context.organization.activeOrganizationId }) + : await plugins.api().resolveTenant({ accountId: context.near?.primaryAccountId ?? "" }); +``` + +Build a local `requireTenant` middleware in your own plugin (mirrors `requireOrganization`): + +```ts +const requireTenant = builder.middleware(async ({ context, next }) => { + const activeOrgId = context.organization?.activeOrganizationId; + const tenant = activeOrgId + ? await plugins.api().resolveTenantByOrgId({ orgId: activeOrgId }).catch(() => null) + : null; + if (!tenant) { + throw new ORPCError("FORBIDDEN", { message: "No tenant found for this organization" }); + } + return next({ context: { ...context, tenant } }); +}); + +builder.listReports.use(requireTenant).handler(async ({ context }) => { + return await services.reports.listByTenant(context.tenant.id); // always filtered +}); +``` + +**Row-level convention (interim isolation)**: add a `tenantId` column to any table holding +tenant-specific application data, resolved server-side and never trusted from client input — +same discipline the `tenants` table itself uses for `orgId` scoping. See +`plugins/_template/src/db/schema.ts` for a commented example table. + +**Forward path**: the target architecture (see `plans/beta-v2-tenants.md`) is per-tenant-per-plugin +Postgres schema isolation (`tenant__plugin_`, `search_path` injected per request). That +requires request-scoped DB access instead of the current initialize-time singleton pattern — a +larger change, only worth it once there's a real multi-tenant plugin ecosystem to isolate. The +`tenantId` column convention above is forward-compatible: when schema isolation lands, the column +becomes redundant and can be dropped without reworking query logic. + ## Generated Types See `references/generated-types.md` for the full table — files, contents, and regeneration triggers. diff --git a/packages/everything-dev/src/infra/planner.ts b/packages/everything-dev/src/infra/planner.ts index 2efb3939..c0dacc98 100644 --- a/packages/everything-dev/src/infra/planner.ts +++ b/packages/everything-dev/src/infra/planner.ts @@ -453,6 +453,9 @@ export function planInfra(input: InfraInput): Effect.Effect { const scriptMap = new Map(); for (const match of router.state.matches) { - const route = - ( - router as AnyRouter & { - routesById?: Record unknown } }>; - } - ).routesById?.[(match as { routeId: string }).routeId] ?? - (match as { route?: { options?: { head?: (...args: unknown[]) => unknown } } }).route; - const headFn = route?.options?.head; - if (!headFn) continue; + const matchData = match as { + meta?: HeadMeta[]; + links?: HeadLink[]; + headScripts?: HeadScript[]; + }; - try { - const headResult = (await headFn({ - loaderData: match.loaderData, - matches: router.state.matches, - match, - params: match.params, - })) as { - meta?: HeadMeta[]; - links?: HeadLink[]; - scripts?: HeadScript[]; - }; - - if (headResult?.meta) { - for (const meta of headResult.meta) { - metaMap.set(getMetaKey(meta), meta); - } + if (matchData.meta) { + for (const meta of matchData.meta) { + metaMap.set(getMetaKey(meta), meta); } - if (headResult?.links) { - for (const link of headResult.links) { - linkMap.set(getLinkKey(link), link); - } + } + if (matchData.links) { + for (const link of matchData.links) { + linkMap.set(getLinkKey(link), link); } - if (headResult?.scripts) { - for (const script of headResult.scripts) { - scriptMap.set(getScriptKey(script), script); - } + } + if (matchData.headScripts) { + for (const script of matchData.headScripts) { + scriptMap.set(getScriptKey(script), script); } - } catch (error) { - console.warn(`[collectHeadData] head() failed for ${match.routeId}:`, error); } } diff --git a/plugins/_template/src/db/schema.ts b/plugins/_template/src/db/schema.ts index b355bb35..51574d0f 100644 --- a/plugins/_template/src/db/schema.ts +++ b/plugins/_template/src/db/schema.ts @@ -14,3 +14,21 @@ export const things = pgTable( }, (table) => [index("things_type_idx").on(table.type)], ); + +// Tenant-scoped table convention (interim, row-level isolation): +// +// If your plugin stores data that belongs to a specific tenant deployment +// (not just a user or org), add a `tenantId` column and filter every query +// by it. Resolve the tenant via the API plugin's public lookup routes +// (`pluginsClient.api.resolveTenantByOrgId` / `resolveTenant`) and never +// trust a tenantId passed directly from client input. +// +// export const reports = pgTable("reports", { +// id: uuid("id").defaultRandom().primaryKey(), +// tenantId: text("tenant_id").notNull(), // resolved server-side, always filtered +// title: text("title").notNull(), +// createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), +// }, (table) => [index("reports_tenant_id_idx").on(table.tenantId)]); +// +// See the api-and-auth skill's "Tenant-Scoped Data" section for the full +// middleware pattern and the forward path to per-tenant schema isolation. diff --git a/tests/regression/browser/helpers/page-ready.ts b/tests/regression/browser/helpers/page-ready.ts index f508dec3..dab7f790 100644 --- a/tests/regression/browser/helpers/page-ready.ts +++ b/tests/regression/browser/helpers/page-ready.ts @@ -33,6 +33,24 @@ export async function waitForApp(page: Page): Promise { return typeof window.__RUNTIME_CONFIG__ !== "undefined"; }); expect(hasRuntimeConfig).toBeTruthy(); + + try { + await page.waitForFunction( + () => { + const p = (window as { __EVERYTHING_DEV_HYDRATE_PROMISE__?: Promise }) + .__EVERYTHING_DEV_HYDRATE_PROMISE__; + return p !== undefined; + }, + { timeout: 5000 }, + ); + await page.evaluate(async () => { + const p = (window as { __EVERYTHING_DEV_HYDRATE_PROMISE__?: Promise }) + .__EVERYTHING_DEV_HYDRATE_PROMISE__; + if (p) await p; + }); + } catch { + // Hydration promise never surfaced (e.g. client-shell fallback path) — proceed. + } } export function expectNoHydrationFailure(errors: PageErrors) { diff --git a/tests/regression/browser/helpers/seeded.ts b/tests/regression/browser/helpers/seeded.ts index 26c1efce..560e0aad 100644 --- a/tests/regression/browser/helpers/seeded.ts +++ b/tests/regression/browser/helpers/seeded.ts @@ -45,5 +45,5 @@ export async function verifyAuthenticated(page: Page) { await page.goto("/home", { waitUntil: "domcontentloaded" }); await page.waitForTimeout(500); await page.waitForLoadState("networkidle"); - await expect(page.locator("button[title='menu']")).toBeVisible({ timeout: 10000 }); + await expect(page.locator("button[title='account menu']")).toBeVisible({ timeout: 10000 }); } diff --git a/tests/regression/browser/specs/auth-redirect.spec.ts b/tests/regression/browser/specs/auth-redirect.spec.ts index 94a79c21..f0b3aede 100644 --- a/tests/regression/browser/specs/auth-redirect.spec.ts +++ b/tests/regression/browser/specs/auth-redirect.spec.ts @@ -26,8 +26,8 @@ test.describe("Auth redirect", () => { expectNoHydrationFailure(pageErrors); }); - test("unauthenticated / redirects to /login", async ({ page }) => { - await page.goto("/", { waitUntil: "domcontentloaded" }); + test("unauthenticated /home redirects to /login", async ({ page }) => { + await page.goto("/home", { waitUntil: "domcontentloaded" }); await waitForApp(page); await page.waitForURL(/\/login/, { timeout: 15000 }); diff --git a/tests/regression/browser/specs/logout.spec.ts b/tests/regression/browser/specs/logout.spec.ts index c207bb95..cef410e9 100644 --- a/tests/regression/browser/specs/logout.spec.ts +++ b/tests/regression/browser/specs/logout.spec.ts @@ -8,7 +8,7 @@ test.describe("logout", () => { pageErrors = collectErrors(page); }); - test("sign out redirects to login and session is cleared", async ({ page }) => { + test("sign out lands on public page and session is cleared", async ({ page }) => { await page.goto("/login", { waitUntil: "domcontentloaded" }); await waitForApp(page); @@ -28,18 +28,20 @@ test.describe("logout", () => { await page.waitForURL(/\/home$/, { timeout: 15000 }); await page.waitForLoadState("networkidle"); await waitForApp(page); - await page.locator("button[title='menu']").click(); + await page.locator("button[title='account menu']").click(); const signOutItem = page.getByRole("menuitem", { name: "sign out" }); await expect(signOutItem).toBeVisible({ timeout: 5000 }); await signOutItem.click(); - await page.waitForURL(/\/login/, { timeout: 15000 }); - await expect(page.getByText("continue anonymously")).toBeVisible({ timeout: 10000 }); + await page.waitForURL(/\/$/, { timeout: 15000 }); + await expect(page.getByText("Get started")).toBeVisible({ timeout: 10000 }); + await expect(page.locator("button[title='account menu']")).toHaveCount(0); await page.reload({ waitUntil: "domcontentloaded" }); await waitForApp(page); - await expect(page.getByText("continue anonymously")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Get started")).toBeVisible({ timeout: 10000 }); + await expect(page.locator("button[title='account menu']")).toHaveCount(0); expectNoHydrationFailure(pageErrors); }); diff --git a/tests/regression/browser/specs/theme.spec.ts b/tests/regression/browser/specs/theme.spec.ts index c8bd569a..0b9a3db8 100644 --- a/tests/regression/browser/specs/theme.spec.ts +++ b/tests/regression/browser/specs/theme.spec.ts @@ -49,19 +49,8 @@ test.describe("theme", () => { await expect(html).toHaveClass(/dark/); - const darkToggle = page.locator("button[aria-label='Switch to light theme']").first(); - await expect(darkToggle).toBeVisible({ timeout: 10000 }); - await darkToggle.click(); - - await expect(html).not.toHaveClass(/dark/); - - const storedAfterLight = await page.evaluate(() => localStorage.getItem("theme")); - expect(storedAfterLight).toBe("light"); - - await page.reload({ waitUntil: "domcontentloaded" }); - await waitForApp(page); - - await expect(html).not.toHaveClass(/dark/); + const storedAfterReload = await page.evaluate(() => localStorage.getItem("theme")); + expect(storedAfterReload).toBe("dark"); expectNoHydrationFailure(pageErrors); }); diff --git a/tests/regression/http/metadata_test.go b/tests/regression/http/metadata_test.go index 856cea48..7382a275 100644 --- a/tests/regression/http/metadata_test.go +++ b/tests/regression/http/metadata_test.go @@ -1,6 +1,7 @@ package regression import ( + "strings" "testing" "everything.dev/regression/http/internal/regtest" @@ -33,3 +34,30 @@ func TestRouteMetadata(t *testing.T) { t.Fatal("route HTML missing tag") } } + +func TestProfileMetadata(t *testing.T) { + const accountID = "root.near" + + client := regtest.NewCookieClient() + status, _, body := regtest.GetRaw(t, client, baseURL+"/"+accountID) + regtest.MustStatus(t, status, 200, body) + + if !regtest.HTMLContainsTitle(body) { + t.Fatal("profile HTML missing <title> tag") + } + if !strings.Contains(body, accountID) { + t.Fatalf("profile HTML missing account id %q", accountID) + } + + for _, property := range []string{"og:title", "og:description", "og:type"} { + if !regtest.HTMLContainsMetaProperty(body, property) { + t.Fatalf("profile HTML missing %s meta property", property) + } + } + + for _, name := range []string{"twitter:card", "twitter:title", "twitter:description"} { + if !regtest.HTMLContainsMetaName(body, name) { + t.Fatalf("profile HTML missing %s meta name", name) + } + } +} diff --git a/ui/rsbuild.config.ts b/ui/rsbuild.config.ts index 8a37905c..b829ffe5 100644 --- a/ui/rsbuild.config.ts +++ b/ui/rsbuild.config.ts @@ -260,7 +260,7 @@ function createServerConfig() { }, }, server: { - port: 3004, + port: Number(process.env.PORT) || 3004, printUrls: ({ urls }) => urls.filter((url) => url.includes("localhost")), headers: { "Access-Control-Allow-Origin": "*", diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts index 476a1331..f7bcfd5c 100644 --- a/ui/src/components/index.ts +++ b/ui/src/components/index.ts @@ -9,6 +9,7 @@ export { ConfirmDialog } from "./confirm-dialog"; export { EmptyState } from "./empty-state"; export { PageContainer } from "./layout/page-container"; export { OrgSwitcher } from "./org-switcher"; +export { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; export { Badge } from "./ui/badge"; export { Button } from "./ui/button"; export { diff --git a/ui/src/components/user-nav.tsx b/ui/src/components/user-nav.tsx index a77e1c08..5929d4c4 100644 --- a/ui/src/components/user-nav.tsx +++ b/ui/src/components/user-nav.tsx @@ -1,17 +1,18 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate, useRouter } from "@tanstack/react-router"; +import { Building2, Home, LogOut, Settings, User } from "lucide-react"; import { useMemo } from "react"; import type { Organization } from "@/app"; import { sessionQueryOptions, useAuthClient } from "@/app"; -import { OrgSwitcher } from "@/components"; +import { Avatar, AvatarFallback, AvatarImage, OrgSwitcher } from "@/components"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { getNearInitials, resolveNearImageUrl } from "@/lib/near-profile"; export function UserNav() { const auth = useAuthClient(); @@ -20,6 +21,8 @@ export function UserNav() { const router = useRouter(); const { data: session } = useQuery(sessionQueryOptions(auth)); const user = session?.user; + const nearAccountId = auth.near.getAccountId(); + const { data: organizations } = useQuery({ queryKey: ["organizations"], queryFn: async () => { @@ -35,6 +38,16 @@ export function UserNav() { return organizations?.find((org) => org.id === activeOrgId); }, [organizations, activeOrgId]); + const { data: nearProfile } = useQuery({ + queryKey: ["near-profile", nearAccountId], + queryFn: async () => { + const { data } = await auth.near.getProfile(nearAccountId ?? undefined); + return data ?? null; + }, + enabled: !!nearAccountId, + staleTime: 5 * 60 * 1000, + }); + const signOutMutation = useMutation({ mutationFn: async () => { const { error } = await auth.signOut(); @@ -71,6 +84,28 @@ export function UserNav() { await queryClient.invalidateQueries({ queryKey: ["organizations"] }); }; + const avatarSrc = resolveNearImageUrl(nearProfile?.image) ?? user.image ?? undefined; + const validEmail = !user.isAnonymous && user.email ? user.email : null; + const displayName = nearProfile?.name || user.name || nearAccountId || validEmail || "guest"; + const handle = nearAccountId || validEmail || "anonymous session"; + const showHandle = handle !== displayName; + const initials = getNearInitials(nearProfile?.name || user.name || nearAccountId); + + const identityContent = ( + <> + <Avatar className="size-9 shrink-0 ring-1 ring-border"> + {avatarSrc ? <AvatarImage src={avatarSrc} alt="" /> : null} + <AvatarFallback className="text-xs font-semibold"> + {initials || <User className="size-4" />} + </AvatarFallback> + </Avatar> + <div className="min-w-0 flex-1"> + <p className="truncate text-sm font-medium text-foreground">{displayName}</p> + {showHandle && <p className="truncate text-xs text-muted-foreground">{handle}</p>} + </div> + </> + ); + return ( <div className="flex items-center gap-2"> {organizations && organizations.length > 0 && ( @@ -85,30 +120,48 @@ export function UserNav() { <DropdownMenuTrigger asChild> <button type="button" - className="w-6 h-6 rounded-full! bg-foreground transition-all duration-200 ease-out hover:shadow-lg hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" - title="menu" - /> + aria-label={displayName} + className="rounded-full! ring-1 ring-border transition-transform duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:scale-105" + title="account menu" + > + <Avatar className="size-8"> + {avatarSrc ? <AvatarImage src={avatarSrc} alt="" /> : null} + <AvatarFallback className="text-xs font-semibold"> + {initials || <User className="size-4" />} + </AvatarFallback> + </Avatar> + </button> </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-56"> - <DropdownMenuLabel> - <div className="space-y-1"> - <p className="text-xs text-muted-foreground">signed in as</p> - <p className="truncate text-sm font-normal">{user.email || user.id}</p> - </div> - </DropdownMenuLabel> + <DropdownMenuContent align="end" className="w-64"> + <DropdownMenuItem asChild> + {nearAccountId ? ( + <Link to="/$accountId" params={{ accountId: nearAccountId }}> + {identityContent} + </Link> + ) : ( + <Link to="/settings/profile">{identityContent}</Link> + )} + </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem asChild> - <Link to="/home">workspace</Link> + <Link to="/home"> + <Home /> + workspace + </Link> </DropdownMenuItem> {activeOrg && ( <DropdownMenuItem asChild> <Link to="/organizations/$slug" params={{ slug: activeOrg.slug }}> + <Building2 /> {activeOrg.name} </Link> </DropdownMenuItem> )} <DropdownMenuItem asChild> - <Link to="/settings">settings</Link> + <Link to="/settings"> + <Settings /> + settings + </Link> </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem @@ -119,6 +172,7 @@ export function UserNav() { }} disabled={signOutMutation.isPending} > + <LogOut /> {signOutMutation.isPending ? "signing out..." : "sign out"} </DropdownMenuItem> </DropdownMenuContent> diff --git a/ui/src/lib/near-profile.ts b/ui/src/lib/near-profile.ts new file mode 100644 index 00000000..deb01fa8 --- /dev/null +++ b/ui/src/lib/near-profile.ts @@ -0,0 +1,17 @@ +export interface NearProfileImage { + url?: string; + ipfs_cid?: string; +} + +export function resolveNearImageUrl(image?: NearProfileImage | null): string | undefined { + if (image?.url) return image.url; + if (image?.ipfs_cid) return `https://ipfs.near.social/ipfs/${image.ipfs_cid}`; + return undefined; +} + +export function getNearInitials(name?: string | null): string { + if (!name) return ""; + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + return name.trim().slice(0, 2).toUpperCase(); +} diff --git a/ui/src/routeTree.gen.ts b/ui/src/routeTree.gen.ts index c7659df3..7bb4ab92 100644 --- a/ui/src/routeTree.gen.ts +++ b/ui/src/routeTree.gen.ts @@ -17,8 +17,10 @@ import { Route as LayoutAppsIndexRouteImport } from './routes/_layout/apps/index import { Route as LayoutPublicIndexRouteImport } from './routes/_layout/_public/index' import { Route as LayoutThingsLiveRouteImport } from './routes/_layout/things/live' import { Route as LayoutThingsThingIdRouteImport } from './routes/_layout/things/$thingId' +import { Route as LayoutPublicSkillRouteImport } from './routes/_layout/_public/skill' import { Route as LayoutPublicLoginRouteImport } from './routes/_layout/_public/login' import { Route as LayoutPublicAboutRouteImport } from './routes/_layout/_public/about' +import { Route as LayoutPublicAccountIdRouteImport } from './routes/_layout/_public/$accountId' import { Route as LayoutAuthenticatedSettingsRouteImport } from './routes/_layout/_authenticated/settings' import { Route as LayoutAuthenticatedHomeRouteImport } from './routes/_layout/_authenticated/home' import { Route as LayoutAuthenticatedAdminRouteImport } from './routes/_layout/_authenticated/admin' @@ -75,6 +77,11 @@ const LayoutThingsThingIdRoute = LayoutThingsThingIdRouteImport.update({ path: '/things/$thingId', getParentRoute: () => LayoutRoute, } as any) +const LayoutPublicSkillRoute = LayoutPublicSkillRouteImport.update({ + id: '/skill', + path: '/skill', + getParentRoute: () => LayoutPublicRoute, +} as any) const LayoutPublicLoginRoute = LayoutPublicLoginRouteImport.update({ id: '/login', path: '/login', @@ -85,6 +92,11 @@ const LayoutPublicAboutRoute = LayoutPublicAboutRouteImport.update({ path: '/about', getParentRoute: () => LayoutPublicRoute, } as any) +const LayoutPublicAccountIdRoute = LayoutPublicAccountIdRouteImport.update({ + id: '/$accountId', + path: '/$accountId', + getParentRoute: () => LayoutPublicRoute, +} as any) const LayoutAuthenticatedSettingsRoute = LayoutAuthenticatedSettingsRouteImport.update({ id: '/settings', @@ -198,8 +210,10 @@ export interface FileRoutesByFullPath { '/admin': typeof LayoutAuthenticatedAdminRouteWithChildren '/home': typeof LayoutAuthenticatedHomeRoute '/settings': typeof LayoutAuthenticatedSettingsRouteWithChildren + '/$accountId': typeof LayoutPublicAccountIdRoute '/about': typeof LayoutPublicAboutRoute '/login': typeof LayoutPublicLoginRoute + '/skill': typeof LayoutPublicSkillRoute '/things/$thingId': typeof LayoutThingsThingIdRoute '/things/live': typeof LayoutThingsLiveRoute '/apps/': typeof LayoutAppsIndexRoute @@ -223,8 +237,10 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof LayoutPublicIndexRoute '/home': typeof LayoutAuthenticatedHomeRoute + '/$accountId': typeof LayoutPublicAccountIdRoute '/about': typeof LayoutPublicAboutRoute '/login': typeof LayoutPublicLoginRoute + '/skill': typeof LayoutPublicSkillRoute '/things/$thingId': typeof LayoutThingsThingIdRoute '/things/live': typeof LayoutThingsLiveRoute '/apps': typeof LayoutAppsIndexRoute @@ -253,8 +269,10 @@ export interface FileRoutesById { '/_layout/_authenticated/admin': typeof LayoutAuthenticatedAdminRouteWithChildren '/_layout/_authenticated/home': typeof LayoutAuthenticatedHomeRoute '/_layout/_authenticated/settings': typeof LayoutAuthenticatedSettingsRouteWithChildren + '/_layout/_public/$accountId': typeof LayoutPublicAccountIdRoute '/_layout/_public/about': typeof LayoutPublicAboutRoute '/_layout/_public/login': typeof LayoutPublicLoginRoute + '/_layout/_public/skill': typeof LayoutPublicSkillRoute '/_layout/things/$thingId': typeof LayoutThingsThingIdRoute '/_layout/things/live': typeof LayoutThingsLiveRoute '/_layout/_public/': typeof LayoutPublicIndexRoute @@ -283,8 +301,10 @@ export interface FileRouteTypes { | '/admin' | '/home' | '/settings' + | '/$accountId' | '/about' | '/login' + | '/skill' | '/things/$thingId' | '/things/live' | '/apps/' @@ -308,8 +328,10 @@ export interface FileRouteTypes { to: | '/' | '/home' + | '/$accountId' | '/about' | '/login' + | '/skill' | '/things/$thingId' | '/things/live' | '/apps' @@ -337,8 +359,10 @@ export interface FileRouteTypes { | '/_layout/_authenticated/admin' | '/_layout/_authenticated/home' | '/_layout/_authenticated/settings' + | '/_layout/_public/$accountId' | '/_layout/_public/about' | '/_layout/_public/login' + | '/_layout/_public/skill' | '/_layout/things/$thingId' | '/_layout/things/live' | '/_layout/_public/' @@ -423,6 +447,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutThingsThingIdRouteImport parentRoute: typeof LayoutRoute } + '/_layout/_public/skill': { + id: '/_layout/_public/skill' + path: '/skill' + fullPath: '/skill' + preLoaderRoute: typeof LayoutPublicSkillRouteImport + parentRoute: typeof LayoutPublicRoute + } '/_layout/_public/login': { id: '/_layout/_public/login' path: '/login' @@ -437,6 +468,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutPublicAboutRouteImport parentRoute: typeof LayoutPublicRoute } + '/_layout/_public/$accountId': { + id: '/_layout/_public/$accountId' + path: '/$accountId' + fullPath: '/$accountId' + preLoaderRoute: typeof LayoutPublicAccountIdRouteImport + parentRoute: typeof LayoutPublicRoute + } '/_layout/_authenticated/settings': { id: '/_layout/_authenticated/settings' path: '/settings' @@ -642,14 +680,18 @@ const LayoutAuthenticatedRouteWithChildren = LayoutAuthenticatedRoute._addFileChildren(LayoutAuthenticatedRouteChildren) interface LayoutPublicRouteChildren { + LayoutPublicAccountIdRoute: typeof LayoutPublicAccountIdRoute LayoutPublicAboutRoute: typeof LayoutPublicAboutRoute LayoutPublicLoginRoute: typeof LayoutPublicLoginRoute + LayoutPublicSkillRoute: typeof LayoutPublicSkillRoute LayoutPublicIndexRoute: typeof LayoutPublicIndexRoute } const LayoutPublicRouteChildren: LayoutPublicRouteChildren = { + LayoutPublicAccountIdRoute: LayoutPublicAccountIdRoute, LayoutPublicAboutRoute: LayoutPublicAboutRoute, LayoutPublicLoginRoute: LayoutPublicLoginRoute, + LayoutPublicSkillRoute: LayoutPublicSkillRoute, LayoutPublicIndexRoute: LayoutPublicIndexRoute, } diff --git a/ui/src/routes/__root.tsx b/ui/src/routes/__root.tsx index 20446243..ea983de9 100644 --- a/ui/src/routes/__root.tsx +++ b/ui/src/routes/__root.tsx @@ -16,7 +16,7 @@ import { Scripts, } from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; -import { getRemoteScripts } from "everything-dev/ui/head"; +import { getRemoteScripts, getThemeInitScript } from "everything-dev/ui/head"; import { getSocialImageMeta } from "everything-dev/ui/metadata"; import { ThemeProvider } from "next-themes"; import type { RouterContext } from "@/app"; @@ -123,6 +123,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({ ...(typeof window === "undefined" ? [{ children: "window.__EVERYTHING_DEV_SSR__=true" }] : []), + getThemeInitScript(), ...getRemoteScripts({ runtimeConfig: runtimeConfig ?? undefined, containerName: "ui", diff --git a/ui/src/routes/_layout.tsx b/ui/src/routes/_layout.tsx index c0a44b0a..3f68e524 100644 --- a/ui/src/routes/_layout.tsx +++ b/ui/src/routes/_layout.tsx @@ -10,6 +10,9 @@ export const Route = createFileRoute("/_layout")({ function Layout() { const isNavigating = useRouterState({ select: (s) => s.status === "pending" }); + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + const [betaBannerDismissed, setBetaBannerDismissed] = useState(() => { if (typeof window === "undefined") return false; return localStorage.getItem("beta-banner-dismissed") === "true"; @@ -45,7 +48,7 @@ function Layout() { </div> )} - {isNavigating && ( + {mounted && isNavigating && ( <div className="fixed top-0 left-0 right-0 h-[2px] z-50 overflow-hidden pointer-events-none"> <div className="h-full bg-foreground animate-progress-bar" style={{ width: "100%" }} /> </div> diff --git a/ui/src/routes/_layout/_authenticated/home.tsx b/ui/src/routes/_layout/_authenticated/home.tsx index e28c6796..1fccff0e 100644 --- a/ui/src/routes/_layout/_authenticated/home.tsx +++ b/ui/src/routes/_layout/_authenticated/home.tsx @@ -2,12 +2,29 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; import { Home as HomeIcon, Settings } from "lucide-react"; import { useMemo } from "react"; -import { type Passkey, type SessionData, sessionQueryOptions, useAuthClient } from "@/app"; +import { + getAccount, + type Passkey, + type SessionData, + sessionQueryOptions, + useAuthClient, +} from "@/app"; import { Card } from "@/components"; import { PageContainer } from "@/components/layout/page-container"; import { InfoRow } from "@/components/ui/info-row"; export const Route = createFileRoute("/_layout/_authenticated/home")({ + beforeLoad: async ({ context }) => { + const { apiClient, runtimeConfig } = context; + const accountId = getAccount(runtimeConfig); + let tenant: Awaited<ReturnType<typeof apiClient.resolveTenant>> | null = null; + try { + tenant = await apiClient.resolveTenant({ accountId }); + } catch { + tenant = null; + } + return { tenant }; + }, head: () => ({ meta: [{ title: "Workspace | app" }, { name: "description", content: "Your workspace." }], }), diff --git a/ui/src/routes/_layout/_public/$accountId.tsx b/ui/src/routes/_layout/_public/$accountId.tsx new file mode 100644 index 00000000..ff1d96e1 --- /dev/null +++ b/ui/src/routes/_layout/_public/$accountId.tsx @@ -0,0 +1,197 @@ +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { getSocialImageMeta } from "everything-dev/ui/metadata"; +import { ExternalLink, Globe, User } from "lucide-react"; +import { useApiClient, useAuthClient } from "@/app"; +import { Avatar, AvatarFallback, AvatarImage, Badge, PageContainer } from "@/components"; +import { getNearInitials, resolveNearImageUrl } from "@/lib/near-profile"; + +export const Route = createFileRoute("/_layout/_public/$accountId")({ + loader: async ({ params, context }) => { + const { queryClient, authClient, apiClient, runtimeConfig } = context; + const accountId = params.accountId; + + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: ["near-profile", accountId], + queryFn: async () => { + const { data } = await authClient.near.getProfile(accountId); + return data ?? null; + }, + staleTime: 5 * 60 * 1000, + }), + queryClient.prefetchQuery({ + queryKey: ["apps-account", accountId], + queryFn: () => apiClient.apps.getRegistryAppsByAccount({ accountId }), + staleTime: 30_000, + }), + ]); + + return { accountId, hostUrl: runtimeConfig?.hostUrl ?? "" }; + }, + head: ({ loaderData, params }) => { + const accountId = params.accountId; + const hostUrl = (loaderData?.hostUrl ?? "").replace(/\/$/, ""); + const siteUrl = hostUrl ? `${hostUrl}/${accountId}` : ""; + const title = `${accountId} | everything.dev`; + const description = `${accountId}'s public profile on everything.dev.`; + + return { + meta: [ + { title }, + { name: "description", content: description }, + ...getSocialImageMeta({ + imageUrl: hostUrl ? `${hostUrl}/metadata.png` : "/metadata.png", + title, + description, + siteName: "everything.dev", + siteUrl, + type: "profile", + alt: description, + }), + ], + }; + }, + component: AccountProfilePage, +}); + +function AccountProfilePage() { + const { accountId } = Route.useLoaderData(); + const authClient = useAuthClient(); + const apiClient = useApiClient(); + + const { data: profile } = useQuery({ + queryKey: ["near-profile", accountId], + queryFn: async () => { + const { data } = await authClient.near.getProfile(accountId); + return data ?? null; + }, + staleTime: 5 * 60 * 1000, + }); + + const { data: appsData } = useQuery({ + queryKey: ["apps-account", accountId], + queryFn: () => apiClient.apps.getRegistryAppsByAccount({ accountId }), + staleTime: 30_000, + }); + + const apps = appsData?.data ?? []; + const backgroundUrl = resolveNearImageUrl(profile?.backgroundImage); + const avatarUrl = resolveNearImageUrl(profile?.image); + const displayName = profile?.name || accountId; + const initials = getNearInitials(profile?.name || accountId); + const linktree = profile?.linktree ? Object.entries(profile.linktree) : []; + + return ( + <PageContainer variant="default"> + <div className="space-y-6"> + <div className="overflow-hidden rounded-[12px] border border-border bg-card"> + <div + className="h-32 sm:h-44 w-full bg-muted" + style={ + backgroundUrl + ? { + backgroundImage: `url(${backgroundUrl})`, + backgroundSize: "cover", + backgroundPosition: "center", + } + : undefined + } + /> + <div className="px-6 pb-6"> + <Avatar className="-mt-10 size-20 border-4 border-card ring-1 ring-border bg-card"> + {avatarUrl ? <AvatarImage src={avatarUrl} alt="" /> : null} + <AvatarFallback className="text-xl font-semibold"> + {initials || <User className="size-8" />} + </AvatarFallback> + </Avatar> + + <div className="mt-3 space-y-1"> + <h1 className="text-xl font-bold text-foreground">{displayName}</h1> + <p className="font-mono text-sm text-muted-foreground">{accountId}</p> + </div> + + {profile?.description && ( + <p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted-foreground"> + {profile.description} + </p> + )} + + {linktree.length > 0 && ( + <div className="mt-4 flex flex-wrap gap-2"> + {linktree.map(([label, url]) => ( + <a + key={label} + href={url} + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-foreground transition-colors hover:bg-border" + > + <Globe className="size-3" /> + {label} + </a> + ))} + </div> + )} + </div> + </div> + + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <h2 className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground"> + Published Gateways + </h2> + {apps.length > 0 && ( + <Link + to="/apps/$accountId" + params={{ accountId }} + className="text-xs font-medium text-muted-foreground hover:text-foreground transition-colors" + > + view all + </Link> + )} + </div> + + {apps.length === 0 ? ( + <div className="rounded-[12px] border border-border bg-card px-6 py-10 text-center text-sm text-muted-foreground"> + No published gateways for{" "} + <span className="font-mono text-foreground">{accountId}</span>. + </div> + ) : ( + <div className="divide-y divide-border overflow-hidden rounded-[12px] border border-border bg-card"> + {apps.slice(0, 5).map((app) => ( + <Link + key={app.gatewayId} + to="/apps/$accountId/$gatewayId" + params={{ accountId, gatewayId: app.gatewayId }} + className="flex items-center justify-between gap-4 px-4 py-3 transition-colors hover:bg-muted/40" + > + <div className="min-w-0 space-y-0.5"> + <div className="flex items-center gap-1.5"> + <span + className={`inline-block h-1.5 w-1.5 shrink-0 rounded-full ${ + app.status === "ready" ? "bg-green-500" : "bg-destructive" + }`} + /> + <span className="truncate font-mono text-sm font-semibold text-foreground"> + {app.metadata?.title ?? app.gatewayId} + </span> + </div> + {app.domain && ( + <Badge variant="secondary" className="font-mono text-[10px]"> + {app.domain} + </Badge> + )} + </div> + {app.openUrl && ( + <ExternalLink className="size-3.5 shrink-0 text-muted-foreground" /> + )} + </Link> + ))} + </div> + )} + </div> + </div> + </PageContainer> + ); +} diff --git a/ui/src/routes/_layout/_public/skill.tsx b/ui/src/routes/_layout/_public/skill.tsx new file mode 100644 index 00000000..d2f83c68 --- /dev/null +++ b/ui/src/routes/_layout/_public/skill.tsx @@ -0,0 +1,126 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Check, Copy, ExternalLink, FileText } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { getAccount, getActiveRuntime, getAppName } from "@/app"; +import { PageContainer } from "@/components/layout/page-container"; +import { Button } from "@/components/ui/button"; +import { Markdown } from "@/components/ui/markdown"; + +const INTENT_REGISTRY_URL = "https://tanstack.com/intent/registry/everything-dev"; + +export const Route = createFileRoute("/_layout/_public/skill")({ + loader: async ({ context }) => { + const runtimeConfig = context.runtimeConfig; + + const skill = await fetch("/skill.md") + .then(async (response) => { + if (!response.ok) { + throw new Error(`Failed to load skill: ${response.status}`); + } + + return response.text(); + }) + .catch(() => null); + + return { + runtimeConfig, + skill, + intentRegistryUrl: INTENT_REGISTRY_URL, + }; + }, + head: () => ({ + meta: [ + { title: "Skill | app" }, + { + name: "description", + content: "Agent-oriented instructions for running, editing, and publishing this runtime.", + }, + ], + }), + component: SkillPage, +}); + +function SkillPage() { + const { skill, runtimeConfig, intentRegistryUrl } = Route.useLoaderData(); + const runtime = getActiveRuntime(runtimeConfig); + const account = getAccount(runtimeConfig); + const appName = getAppName(runtimeConfig); + const [copied, setCopied] = useState(false); + + const accountId = runtime?.accountId ?? account; + + const handleCopy = async () => { + if (!skill) { + toast.error("Skill prompt unavailable"); + return; + } + + await navigator.clipboard.writeText(skill); + setCopied(true); + toast.success("Skill prompt copied"); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + <PageContainer variant="default"> + <div className="space-y-4"> + <div className="rounded-[12px] border border-border bg-card p-6 space-y-4"> + <div className="flex items-start justify-between gap-4 flex-wrap"> + <div className="flex items-center gap-3 min-w-0"> + <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] bg-foreground text-background"> + <FileText size={18} /> + </div> + <div className="min-w-0"> + <div className="flex items-center gap-2 flex-wrap"> + <span className="text-xs font-mono text-muted-foreground">{accountId}</span> + <span className="text-muted-foreground">/</span> + <span className="text-base font-semibold text-foreground">{appName}</span> + </div> + <p className="mt-1 text-sm text-muted-foreground"> + Agent-ready prompt for TanStack Intent, local development, UI changes, and publish + flow. + </p> + </div> + </div> + + <div className="flex flex-wrap items-center gap-2"> + <Button variant="outline" onClick={handleCopy} disabled={!skill}> + {copied ? <Check size={14} /> : <Copy size={14} />} + {copied ? "Copied" : "Copy prompt"} + </Button> + <Button variant="outline" asChild> + <a href="/skill.md" target="_blank" rel="noopener noreferrer"> + <ExternalLink size={14} /> + raw skill.md + </a> + </Button> + <Button asChild> + <a href={intentRegistryUrl} target="_blank" rel="noopener noreferrer"> + <ExternalLink size={14} /> + TanStack Intent + </a> + </Button> + </div> + </div> + + <div className="rounded-[8px] border border-border bg-muted px-3.5 py-3 text-sm text-muted-foreground"> + Best entry points: `npx @tanstack/intent@latest load everything-dev`, `/skill.md`, and + the registry page above. + </div> + </div> + + {skill ? ( + <div className="rounded-[12px] border border-border bg-card p-8"> + <Markdown content={skill} /> + </div> + ) : ( + <div className="flex flex-col items-center justify-center gap-3 rounded-[12px] border border-border bg-card px-8 py-16 text-muted-foreground"> + <FileText size={32} className="text-border" /> + <p className="text-sm text-muted-foreground">Skill prompt unavailable.</p> + </div> + )} + </div> + </PageContainer> + ); +} From daee1041afa7a4de51e7f9283018601fd3040ba8 Mon Sep 17 00:00:00 2001 From: Elliot Braem <elliot@ejlbraem.com> Date: Thu, 13 Aug 2026 17:41:36 -0500 Subject: [PATCH 05/12] refactor(ui): reorganize routes into mount-point layouts, rename home to dashboard - add _admin pathless layout gating on admin role; tenant admin dashboard and system pages render as children via Outlet - rename authenticated /home route to /dashboard; update sidebar, mobile tabs, user nav, and login redirect fallbacks - move apps and things routes under the public layout - changeset: ui-layout-mounts --- .changeset/ui-layout-mounts.md | 9 + ui/src/components/user-nav.tsx | 2 +- ui/src/routeTree.gen.ts | 421 +++++++++--------- ui/src/routes/_layout/_admin.tsx | 62 +++ .../{_authenticated => _admin}/admin.tsx | 54 +-- ui/src/routes/_layout/_admin/admin/index.tsx | 152 +++++++ ui/src/routes/_layout/_admin/admin/system.tsx | 70 +++ ui/src/routes/_layout/_authenticated.tsx | 4 +- .../_layout/_authenticated/admin/index.tsx | 104 ----- .../_layout/_authenticated/admin/system.tsx | 73 --- .../{home.tsx => dashboard.tsx} | 2 +- .../apps/$accountId/$gatewayId.tsx | 2 +- .../{ => _public}/apps/$accountId/index.tsx | 2 +- .../_layout/{ => _public}/apps/index.tsx | 2 +- ui/src/routes/_layout/_public/login.tsx | 6 +- .../_layout/{ => _public}/things/$thingId.tsx | 2 +- .../_layout/{ => _public}/things/index.tsx | 2 +- .../_layout/{ => _public}/things/live.tsx | 2 +- 18 files changed, 532 insertions(+), 439 deletions(-) create mode 100644 .changeset/ui-layout-mounts.md create mode 100644 ui/src/routes/_layout/_admin.tsx rename ui/src/routes/_layout/{_authenticated => _admin}/admin.tsx (65%) create mode 100644 ui/src/routes/_layout/_admin/admin/index.tsx create mode 100644 ui/src/routes/_layout/_admin/admin/system.tsx delete mode 100644 ui/src/routes/_layout/_authenticated/admin/index.tsx delete mode 100644 ui/src/routes/_layout/_authenticated/admin/system.tsx rename ui/src/routes/_layout/_authenticated/{home.tsx => dashboard.tsx} (98%) rename ui/src/routes/_layout/{ => _public}/apps/$accountId/$gatewayId.tsx (99%) rename ui/src/routes/_layout/{ => _public}/apps/$accountId/index.tsx (99%) rename ui/src/routes/_layout/{ => _public}/apps/index.tsx (99%) rename ui/src/routes/_layout/{ => _public}/things/$thingId.tsx (98%) rename ui/src/routes/_layout/{ => _public}/things/index.tsx (97%) rename ui/src/routes/_layout/{ => _public}/things/live.tsx (98%) diff --git a/.changeset/ui-layout-mounts.md b/.changeset/ui-layout-mounts.md new file mode 100644 index 00000000..b9e8dd19 --- /dev/null +++ b/.changeset/ui-layout-mounts.md @@ -0,0 +1,9 @@ +--- +"ui": minor +--- + +Reorganize UI routes into mount-point layouts and rename the authenticated workspace. + +- Move admin routes under a new `/_layout/_admin` pathless layout that gates on the admin role and redirects non-admins to `/dashboard`. The tenant admin dashboard (`admin/admin/index.tsx`) and system page (`admin/admin/system.tsx`) now render as children of the admin layout through an `Outlet`. +- Rename the authenticated `/home` route to `/dashboard`, updating the sidebar, mobile tab bar, user nav, and login redirect fallbacks. +- Move the apps and things routes under the public layout (`_layout/_public/apps`, `_layout/_public/things`) so they render inside the shared public shell instead of the top-level layout. diff --git a/ui/src/components/user-nav.tsx b/ui/src/components/user-nav.tsx index 5929d4c4..ba6a6107 100644 --- a/ui/src/components/user-nav.tsx +++ b/ui/src/components/user-nav.tsx @@ -144,7 +144,7 @@ export function UserNav() { </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem asChild> - <Link to="/home"> + <Link to="/dashboard"> <Home /> workspace </Link> diff --git a/ui/src/routeTree.gen.ts b/ui/src/routeTree.gen.ts index 7bb4ab92..359bd53d 100644 --- a/ui/src/routeTree.gen.ts +++ b/ui/src/routeTree.gen.ts @@ -12,23 +12,22 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutPublicRouteImport } from './routes/_layout/_public' import { Route as LayoutAuthenticatedRouteImport } from './routes/_layout/_authenticated' -import { Route as LayoutThingsIndexRouteImport } from './routes/_layout/things/index' -import { Route as LayoutAppsIndexRouteImport } from './routes/_layout/apps/index' +import { Route as LayoutAdminRouteImport } from './routes/_layout/_admin' import { Route as LayoutPublicIndexRouteImport } from './routes/_layout/_public/index' -import { Route as LayoutThingsLiveRouteImport } from './routes/_layout/things/live' -import { Route as LayoutThingsThingIdRouteImport } from './routes/_layout/things/$thingId' import { Route as LayoutPublicSkillRouteImport } from './routes/_layout/_public/skill' import { Route as LayoutPublicLoginRouteImport } from './routes/_layout/_public/login' import { Route as LayoutPublicAboutRouteImport } from './routes/_layout/_public/about' import { Route as LayoutPublicAccountIdRouteImport } from './routes/_layout/_public/$accountId' import { Route as LayoutAuthenticatedSettingsRouteImport } from './routes/_layout/_authenticated/settings' -import { Route as LayoutAuthenticatedHomeRouteImport } from './routes/_layout/_authenticated/home' -import { Route as LayoutAuthenticatedAdminRouteImport } from './routes/_layout/_authenticated/admin' -import { Route as LayoutAppsAccountIdIndexRouteImport } from './routes/_layout/apps/$accountId/index' +import { Route as LayoutAuthenticatedDashboardRouteImport } from './routes/_layout/_authenticated/dashboard' +import { Route as LayoutAdminAdminRouteImport } from './routes/_layout/_admin/admin' +import { Route as LayoutPublicThingsIndexRouteImport } from './routes/_layout/_public/things/index' +import { Route as LayoutPublicAppsIndexRouteImport } from './routes/_layout/_public/apps/index' import { Route as LayoutAuthenticatedSettingsIndexRouteImport } from './routes/_layout/_authenticated/settings/index' import { Route as LayoutAuthenticatedOrganizationsIndexRouteImport } from './routes/_layout/_authenticated/organizations/index' -import { Route as LayoutAuthenticatedAdminIndexRouteImport } from './routes/_layout/_authenticated/admin/index' -import { Route as LayoutAppsAccountIdGatewayIdRouteImport } from './routes/_layout/apps/$accountId/$gatewayId' +import { Route as LayoutAdminAdminIndexRouteImport } from './routes/_layout/_admin/admin/index' +import { Route as LayoutPublicThingsLiveRouteImport } from './routes/_layout/_public/things/live' +import { Route as LayoutPublicThingsThingIdRouteImport } from './routes/_layout/_public/things/$thingId' import { Route as LayoutAuthenticatedThingsNewRouteImport } from './routes/_layout/_authenticated/things/new' import { Route as LayoutAuthenticatedTenantNewRouteImport } from './routes/_layout/_authenticated/tenant/new' import { Route as LayoutAuthenticatedTenantTenantIdRouteImport } from './routes/_layout/_authenticated/tenant/$tenantId' @@ -37,8 +36,10 @@ import { Route as LayoutAuthenticatedSettingsProfileRouteImport } from './routes import { Route as LayoutAuthenticatedSettingsAuthMethodsRouteImport } from './routes/_layout/_authenticated/settings/auth-methods' import { Route as LayoutAuthenticatedOrganizationsNewRouteImport } from './routes/_layout/_authenticated/organizations/new' import { Route as LayoutAuthenticatedOrganizationsSlugRouteImport } from './routes/_layout/_authenticated/organizations/$slug' -import { Route as LayoutAuthenticatedAdminSystemRouteImport } from './routes/_layout/_authenticated/admin/system' import { Route as LayoutAuthenticatedAcceptInvitationIdRouteImport } from './routes/_layout/_authenticated/accept-invitation.$id' +import { Route as LayoutAdminAdminSystemRouteImport } from './routes/_layout/_admin/admin/system' +import { Route as LayoutPublicAppsAccountIdIndexRouteImport } from './routes/_layout/_public/apps/$accountId/index' +import { Route as LayoutPublicAppsAccountIdGatewayIdRouteImport } from './routes/_layout/_public/apps/$accountId/$gatewayId' const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', @@ -52,14 +53,8 @@ const LayoutAuthenticatedRoute = LayoutAuthenticatedRouteImport.update({ id: '/_authenticated', getParentRoute: () => LayoutRoute, } as any) -const LayoutThingsIndexRoute = LayoutThingsIndexRouteImport.update({ - id: '/things/', - path: '/things/', - getParentRoute: () => LayoutRoute, -} as any) -const LayoutAppsIndexRoute = LayoutAppsIndexRouteImport.update({ - id: '/apps/', - path: '/apps/', +const LayoutAdminRoute = LayoutAdminRouteImport.update({ + id: '/_admin', getParentRoute: () => LayoutRoute, } as any) const LayoutPublicIndexRoute = LayoutPublicIndexRouteImport.update({ @@ -67,16 +62,6 @@ const LayoutPublicIndexRoute = LayoutPublicIndexRouteImport.update({ path: '/', getParentRoute: () => LayoutPublicRoute, } as any) -const LayoutThingsLiveRoute = LayoutThingsLiveRouteImport.update({ - id: '/things/live', - path: '/things/live', - getParentRoute: () => LayoutRoute, -} as any) -const LayoutThingsThingIdRoute = LayoutThingsThingIdRouteImport.update({ - id: '/things/$thingId', - path: '/things/$thingId', - getParentRoute: () => LayoutRoute, -} as any) const LayoutPublicSkillRoute = LayoutPublicSkillRouteImport.update({ id: '/skill', path: '/skill', @@ -103,23 +88,27 @@ const LayoutAuthenticatedSettingsRoute = path: '/settings', getParentRoute: () => LayoutAuthenticatedRoute, } as any) -const LayoutAuthenticatedHomeRoute = LayoutAuthenticatedHomeRouteImport.update({ - id: '/home', - path: '/home', - getParentRoute: () => LayoutAuthenticatedRoute, -} as any) -const LayoutAuthenticatedAdminRoute = - LayoutAuthenticatedAdminRouteImport.update({ - id: '/admin', - path: '/admin', +const LayoutAuthenticatedDashboardRoute = + LayoutAuthenticatedDashboardRouteImport.update({ + id: '/dashboard', + path: '/dashboard', getParentRoute: () => LayoutAuthenticatedRoute, } as any) -const LayoutAppsAccountIdIndexRoute = - LayoutAppsAccountIdIndexRouteImport.update({ - id: '/apps/$accountId/', - path: '/apps/$accountId/', - getParentRoute: () => LayoutRoute, - } as any) +const LayoutAdminAdminRoute = LayoutAdminAdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => LayoutAdminRoute, +} as any) +const LayoutPublicThingsIndexRoute = LayoutPublicThingsIndexRouteImport.update({ + id: '/things/', + path: '/things/', + getParentRoute: () => LayoutPublicRoute, +} as any) +const LayoutPublicAppsIndexRoute = LayoutPublicAppsIndexRouteImport.update({ + id: '/apps/', + path: '/apps/', + getParentRoute: () => LayoutPublicRoute, +} as any) const LayoutAuthenticatedSettingsIndexRoute = LayoutAuthenticatedSettingsIndexRouteImport.update({ id: '/', @@ -132,17 +121,21 @@ const LayoutAuthenticatedOrganizationsIndexRoute = path: '/organizations/', getParentRoute: () => LayoutAuthenticatedRoute, } as any) -const LayoutAuthenticatedAdminIndexRoute = - LayoutAuthenticatedAdminIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => LayoutAuthenticatedAdminRoute, - } as any) -const LayoutAppsAccountIdGatewayIdRoute = - LayoutAppsAccountIdGatewayIdRouteImport.update({ - id: '/apps/$accountId/$gatewayId', - path: '/apps/$accountId/$gatewayId', - getParentRoute: () => LayoutRoute, +const LayoutAdminAdminIndexRoute = LayoutAdminAdminIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => LayoutAdminAdminRoute, +} as any) +const LayoutPublicThingsLiveRoute = LayoutPublicThingsLiveRouteImport.update({ + id: '/things/live', + path: '/things/live', + getParentRoute: () => LayoutPublicRoute, +} as any) +const LayoutPublicThingsThingIdRoute = + LayoutPublicThingsThingIdRouteImport.update({ + id: '/things/$thingId', + path: '/things/$thingId', + getParentRoute: () => LayoutPublicRoute, } as any) const LayoutAuthenticatedThingsNewRoute = LayoutAuthenticatedThingsNewRouteImport.update({ @@ -192,34 +185,41 @@ const LayoutAuthenticatedOrganizationsSlugRoute = path: '/organizations/$slug', getParentRoute: () => LayoutAuthenticatedRoute, } as any) -const LayoutAuthenticatedAdminSystemRoute = - LayoutAuthenticatedAdminSystemRouteImport.update({ - id: '/system', - path: '/system', - getParentRoute: () => LayoutAuthenticatedAdminRoute, - } as any) const LayoutAuthenticatedAcceptInvitationIdRoute = LayoutAuthenticatedAcceptInvitationIdRouteImport.update({ id: '/accept-invitation/$id', path: '/accept-invitation/$id', getParentRoute: () => LayoutAuthenticatedRoute, } as any) +const LayoutAdminAdminSystemRoute = LayoutAdminAdminSystemRouteImport.update({ + id: '/system', + path: '/system', + getParentRoute: () => LayoutAdminAdminRoute, +} as any) +const LayoutPublicAppsAccountIdIndexRoute = + LayoutPublicAppsAccountIdIndexRouteImport.update({ + id: '/apps/$accountId/', + path: '/apps/$accountId/', + getParentRoute: () => LayoutPublicRoute, + } as any) +const LayoutPublicAppsAccountIdGatewayIdRoute = + LayoutPublicAppsAccountIdGatewayIdRouteImport.update({ + id: '/apps/$accountId/$gatewayId', + path: '/apps/$accountId/$gatewayId', + getParentRoute: () => LayoutPublicRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof LayoutPublicIndexRoute - '/admin': typeof LayoutAuthenticatedAdminRouteWithChildren - '/home': typeof LayoutAuthenticatedHomeRoute + '/admin': typeof LayoutAdminAdminRouteWithChildren + '/dashboard': typeof LayoutAuthenticatedDashboardRoute '/settings': typeof LayoutAuthenticatedSettingsRouteWithChildren '/$accountId': typeof LayoutPublicAccountIdRoute '/about': typeof LayoutPublicAboutRoute '/login': typeof LayoutPublicLoginRoute '/skill': typeof LayoutPublicSkillRoute - '/things/$thingId': typeof LayoutThingsThingIdRoute - '/things/live': typeof LayoutThingsLiveRoute - '/apps/': typeof LayoutAppsIndexRoute - '/things/': typeof LayoutThingsIndexRoute + '/admin/system': typeof LayoutAdminAdminSystemRoute '/accept-invitation/$id': typeof LayoutAuthenticatedAcceptInvitationIdRoute - '/admin/system': typeof LayoutAuthenticatedAdminSystemRoute '/organizations/$slug': typeof LayoutAuthenticatedOrganizationsSlugRoute '/organizations/new': typeof LayoutAuthenticatedOrganizationsNewRoute '/settings/auth-methods': typeof LayoutAuthenticatedSettingsAuthMethodsRoute @@ -228,25 +228,25 @@ export interface FileRoutesByFullPath { '/tenant/$tenantId': typeof LayoutAuthenticatedTenantTenantIdRoute '/tenant/new': typeof LayoutAuthenticatedTenantNewRoute '/things/new': typeof LayoutAuthenticatedThingsNewRoute - '/apps/$accountId/$gatewayId': typeof LayoutAppsAccountIdGatewayIdRoute - '/admin/': typeof LayoutAuthenticatedAdminIndexRoute + '/things/$thingId': typeof LayoutPublicThingsThingIdRoute + '/things/live': typeof LayoutPublicThingsLiveRoute + '/admin/': typeof LayoutAdminAdminIndexRoute '/organizations/': typeof LayoutAuthenticatedOrganizationsIndexRoute '/settings/': typeof LayoutAuthenticatedSettingsIndexRoute - '/apps/$accountId/': typeof LayoutAppsAccountIdIndexRoute + '/apps/': typeof LayoutPublicAppsIndexRoute + '/things/': typeof LayoutPublicThingsIndexRoute + '/apps/$accountId/$gatewayId': typeof LayoutPublicAppsAccountIdGatewayIdRoute + '/apps/$accountId/': typeof LayoutPublicAppsAccountIdIndexRoute } export interface FileRoutesByTo { '/': typeof LayoutPublicIndexRoute - '/home': typeof LayoutAuthenticatedHomeRoute + '/dashboard': typeof LayoutAuthenticatedDashboardRoute '/$accountId': typeof LayoutPublicAccountIdRoute '/about': typeof LayoutPublicAboutRoute '/login': typeof LayoutPublicLoginRoute '/skill': typeof LayoutPublicSkillRoute - '/things/$thingId': typeof LayoutThingsThingIdRoute - '/things/live': typeof LayoutThingsLiveRoute - '/apps': typeof LayoutAppsIndexRoute - '/things': typeof LayoutThingsIndexRoute + '/admin/system': typeof LayoutAdminAdminSystemRoute '/accept-invitation/$id': typeof LayoutAuthenticatedAcceptInvitationIdRoute - '/admin/system': typeof LayoutAuthenticatedAdminSystemRoute '/organizations/$slug': typeof LayoutAuthenticatedOrganizationsSlugRoute '/organizations/new': typeof LayoutAuthenticatedOrganizationsNewRoute '/settings/auth-methods': typeof LayoutAuthenticatedSettingsAuthMethodsRoute @@ -255,31 +255,32 @@ export interface FileRoutesByTo { '/tenant/$tenantId': typeof LayoutAuthenticatedTenantTenantIdRoute '/tenant/new': typeof LayoutAuthenticatedTenantNewRoute '/things/new': typeof LayoutAuthenticatedThingsNewRoute - '/apps/$accountId/$gatewayId': typeof LayoutAppsAccountIdGatewayIdRoute - '/admin': typeof LayoutAuthenticatedAdminIndexRoute + '/things/$thingId': typeof LayoutPublicThingsThingIdRoute + '/things/live': typeof LayoutPublicThingsLiveRoute + '/admin': typeof LayoutAdminAdminIndexRoute '/organizations': typeof LayoutAuthenticatedOrganizationsIndexRoute '/settings': typeof LayoutAuthenticatedSettingsIndexRoute - '/apps/$accountId': typeof LayoutAppsAccountIdIndexRoute + '/apps': typeof LayoutPublicAppsIndexRoute + '/things': typeof LayoutPublicThingsIndexRoute + '/apps/$accountId/$gatewayId': typeof LayoutPublicAppsAccountIdGatewayIdRoute + '/apps/$accountId': typeof LayoutPublicAppsAccountIdIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_layout': typeof LayoutRouteWithChildren + '/_layout/_admin': typeof LayoutAdminRouteWithChildren '/_layout/_authenticated': typeof LayoutAuthenticatedRouteWithChildren '/_layout/_public': typeof LayoutPublicRouteWithChildren - '/_layout/_authenticated/admin': typeof LayoutAuthenticatedAdminRouteWithChildren - '/_layout/_authenticated/home': typeof LayoutAuthenticatedHomeRoute + '/_layout/_admin/admin': typeof LayoutAdminAdminRouteWithChildren + '/_layout/_authenticated/dashboard': typeof LayoutAuthenticatedDashboardRoute '/_layout/_authenticated/settings': typeof LayoutAuthenticatedSettingsRouteWithChildren '/_layout/_public/$accountId': typeof LayoutPublicAccountIdRoute '/_layout/_public/about': typeof LayoutPublicAboutRoute '/_layout/_public/login': typeof LayoutPublicLoginRoute '/_layout/_public/skill': typeof LayoutPublicSkillRoute - '/_layout/things/$thingId': typeof LayoutThingsThingIdRoute - '/_layout/things/live': typeof LayoutThingsLiveRoute '/_layout/_public/': typeof LayoutPublicIndexRoute - '/_layout/apps/': typeof LayoutAppsIndexRoute - '/_layout/things/': typeof LayoutThingsIndexRoute + '/_layout/_admin/admin/system': typeof LayoutAdminAdminSystemRoute '/_layout/_authenticated/accept-invitation/$id': typeof LayoutAuthenticatedAcceptInvitationIdRoute - '/_layout/_authenticated/admin/system': typeof LayoutAuthenticatedAdminSystemRoute '/_layout/_authenticated/organizations/$slug': typeof LayoutAuthenticatedOrganizationsSlugRoute '/_layout/_authenticated/organizations/new': typeof LayoutAuthenticatedOrganizationsNewRoute '/_layout/_authenticated/settings/auth-methods': typeof LayoutAuthenticatedSettingsAuthMethodsRoute @@ -288,29 +289,29 @@ export interface FileRoutesById { '/_layout/_authenticated/tenant/$tenantId': typeof LayoutAuthenticatedTenantTenantIdRoute '/_layout/_authenticated/tenant/new': typeof LayoutAuthenticatedTenantNewRoute '/_layout/_authenticated/things/new': typeof LayoutAuthenticatedThingsNewRoute - '/_layout/apps/$accountId/$gatewayId': typeof LayoutAppsAccountIdGatewayIdRoute - '/_layout/_authenticated/admin/': typeof LayoutAuthenticatedAdminIndexRoute + '/_layout/_public/things/$thingId': typeof LayoutPublicThingsThingIdRoute + '/_layout/_public/things/live': typeof LayoutPublicThingsLiveRoute + '/_layout/_admin/admin/': typeof LayoutAdminAdminIndexRoute '/_layout/_authenticated/organizations/': typeof LayoutAuthenticatedOrganizationsIndexRoute '/_layout/_authenticated/settings/': typeof LayoutAuthenticatedSettingsIndexRoute - '/_layout/apps/$accountId/': typeof LayoutAppsAccountIdIndexRoute + '/_layout/_public/apps/': typeof LayoutPublicAppsIndexRoute + '/_layout/_public/things/': typeof LayoutPublicThingsIndexRoute + '/_layout/_public/apps/$accountId/$gatewayId': typeof LayoutPublicAppsAccountIdGatewayIdRoute + '/_layout/_public/apps/$accountId/': typeof LayoutPublicAppsAccountIdIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/admin' - | '/home' + | '/dashboard' | '/settings' | '/$accountId' | '/about' | '/login' | '/skill' - | '/things/$thingId' - | '/things/live' - | '/apps/' - | '/things/' - | '/accept-invitation/$id' | '/admin/system' + | '/accept-invitation/$id' | '/organizations/$slug' | '/organizations/new' | '/settings/auth-methods' @@ -319,25 +320,25 @@ export interface FileRouteTypes { | '/tenant/$tenantId' | '/tenant/new' | '/things/new' - | '/apps/$accountId/$gatewayId' + | '/things/$thingId' + | '/things/live' | '/admin/' | '/organizations/' | '/settings/' + | '/apps/' + | '/things/' + | '/apps/$accountId/$gatewayId' | '/apps/$accountId/' fileRoutesByTo: FileRoutesByTo to: | '/' - | '/home' + | '/dashboard' | '/$accountId' | '/about' | '/login' | '/skill' - | '/things/$thingId' - | '/things/live' - | '/apps' - | '/things' - | '/accept-invitation/$id' | '/admin/system' + | '/accept-invitation/$id' | '/organizations/$slug' | '/organizations/new' | '/settings/auth-methods' @@ -346,30 +347,31 @@ export interface FileRouteTypes { | '/tenant/$tenantId' | '/tenant/new' | '/things/new' - | '/apps/$accountId/$gatewayId' + | '/things/$thingId' + | '/things/live' | '/admin' | '/organizations' | '/settings' + | '/apps' + | '/things' + | '/apps/$accountId/$gatewayId' | '/apps/$accountId' id: | '__root__' | '/_layout' + | '/_layout/_admin' | '/_layout/_authenticated' | '/_layout/_public' - | '/_layout/_authenticated/admin' - | '/_layout/_authenticated/home' + | '/_layout/_admin/admin' + | '/_layout/_authenticated/dashboard' | '/_layout/_authenticated/settings' | '/_layout/_public/$accountId' | '/_layout/_public/about' | '/_layout/_public/login' | '/_layout/_public/skill' - | '/_layout/things/$thingId' - | '/_layout/things/live' | '/_layout/_public/' - | '/_layout/apps/' - | '/_layout/things/' + | '/_layout/_admin/admin/system' | '/_layout/_authenticated/accept-invitation/$id' - | '/_layout/_authenticated/admin/system' | '/_layout/_authenticated/organizations/$slug' | '/_layout/_authenticated/organizations/new' | '/_layout/_authenticated/settings/auth-methods' @@ -378,11 +380,15 @@ export interface FileRouteTypes { | '/_layout/_authenticated/tenant/$tenantId' | '/_layout/_authenticated/tenant/new' | '/_layout/_authenticated/things/new' - | '/_layout/apps/$accountId/$gatewayId' - | '/_layout/_authenticated/admin/' + | '/_layout/_public/things/$thingId' + | '/_layout/_public/things/live' + | '/_layout/_admin/admin/' | '/_layout/_authenticated/organizations/' | '/_layout/_authenticated/settings/' - | '/_layout/apps/$accountId/' + | '/_layout/_public/apps/' + | '/_layout/_public/things/' + | '/_layout/_public/apps/$accountId/$gatewayId' + | '/_layout/_public/apps/$accountId/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -412,18 +418,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedRouteImport parentRoute: typeof LayoutRoute } - '/_layout/things/': { - id: '/_layout/things/' - path: '/things' - fullPath: '/things/' - preLoaderRoute: typeof LayoutThingsIndexRouteImport - parentRoute: typeof LayoutRoute - } - '/_layout/apps/': { - id: '/_layout/apps/' - path: '/apps' - fullPath: '/apps/' - preLoaderRoute: typeof LayoutAppsIndexRouteImport + '/_layout/_admin': { + id: '/_layout/_admin' + path: '' + fullPath: '/' + preLoaderRoute: typeof LayoutAdminRouteImport parentRoute: typeof LayoutRoute } '/_layout/_public/': { @@ -433,20 +432,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutPublicIndexRouteImport parentRoute: typeof LayoutPublicRoute } - '/_layout/things/live': { - id: '/_layout/things/live' - path: '/things/live' - fullPath: '/things/live' - preLoaderRoute: typeof LayoutThingsLiveRouteImport - parentRoute: typeof LayoutRoute - } - '/_layout/things/$thingId': { - id: '/_layout/things/$thingId' - path: '/things/$thingId' - fullPath: '/things/$thingId' - preLoaderRoute: typeof LayoutThingsThingIdRouteImport - parentRoute: typeof LayoutRoute - } '/_layout/_public/skill': { id: '/_layout/_public/skill' path: '/skill' @@ -482,26 +467,33 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedSettingsRouteImport parentRoute: typeof LayoutAuthenticatedRoute } - '/_layout/_authenticated/home': { - id: '/_layout/_authenticated/home' - path: '/home' - fullPath: '/home' - preLoaderRoute: typeof LayoutAuthenticatedHomeRouteImport + '/_layout/_authenticated/dashboard': { + id: '/_layout/_authenticated/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof LayoutAuthenticatedDashboardRouteImport parentRoute: typeof LayoutAuthenticatedRoute } - '/_layout/_authenticated/admin': { - id: '/_layout/_authenticated/admin' + '/_layout/_admin/admin': { + id: '/_layout/_admin/admin' path: '/admin' fullPath: '/admin' - preLoaderRoute: typeof LayoutAuthenticatedAdminRouteImport - parentRoute: typeof LayoutAuthenticatedRoute + preLoaderRoute: typeof LayoutAdminAdminRouteImport + parentRoute: typeof LayoutAdminRoute } - '/_layout/apps/$accountId/': { - id: '/_layout/apps/$accountId/' - path: '/apps/$accountId' - fullPath: '/apps/$accountId/' - preLoaderRoute: typeof LayoutAppsAccountIdIndexRouteImport - parentRoute: typeof LayoutRoute + '/_layout/_public/things/': { + id: '/_layout/_public/things/' + path: '/things' + fullPath: '/things/' + preLoaderRoute: typeof LayoutPublicThingsIndexRouteImport + parentRoute: typeof LayoutPublicRoute + } + '/_layout/_public/apps/': { + id: '/_layout/_public/apps/' + path: '/apps' + fullPath: '/apps/' + preLoaderRoute: typeof LayoutPublicAppsIndexRouteImport + parentRoute: typeof LayoutPublicRoute } '/_layout/_authenticated/settings/': { id: '/_layout/_authenticated/settings/' @@ -517,19 +509,26 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedOrganizationsIndexRouteImport parentRoute: typeof LayoutAuthenticatedRoute } - '/_layout/_authenticated/admin/': { - id: '/_layout/_authenticated/admin/' + '/_layout/_admin/admin/': { + id: '/_layout/_admin/admin/' path: '/' fullPath: '/admin/' - preLoaderRoute: typeof LayoutAuthenticatedAdminIndexRouteImport - parentRoute: typeof LayoutAuthenticatedAdminRoute + preLoaderRoute: typeof LayoutAdminAdminIndexRouteImport + parentRoute: typeof LayoutAdminAdminRoute } - '/_layout/apps/$accountId/$gatewayId': { - id: '/_layout/apps/$accountId/$gatewayId' - path: '/apps/$accountId/$gatewayId' - fullPath: '/apps/$accountId/$gatewayId' - preLoaderRoute: typeof LayoutAppsAccountIdGatewayIdRouteImport - parentRoute: typeof LayoutRoute + '/_layout/_public/things/live': { + id: '/_layout/_public/things/live' + path: '/things/live' + fullPath: '/things/live' + preLoaderRoute: typeof LayoutPublicThingsLiveRouteImport + parentRoute: typeof LayoutPublicRoute + } + '/_layout/_public/things/$thingId': { + id: '/_layout/_public/things/$thingId' + path: '/things/$thingId' + fullPath: '/things/$thingId' + preLoaderRoute: typeof LayoutPublicThingsThingIdRouteImport + parentRoute: typeof LayoutPublicRoute } '/_layout/_authenticated/things/new': { id: '/_layout/_authenticated/things/new' @@ -587,13 +586,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedOrganizationsSlugRouteImport parentRoute: typeof LayoutAuthenticatedRoute } - '/_layout/_authenticated/admin/system': { - id: '/_layout/_authenticated/admin/system' - path: '/system' - fullPath: '/admin/system' - preLoaderRoute: typeof LayoutAuthenticatedAdminSystemRouteImport - parentRoute: typeof LayoutAuthenticatedAdminRoute - } '/_layout/_authenticated/accept-invitation/$id': { id: '/_layout/_authenticated/accept-invitation/$id' path: '/accept-invitation/$id' @@ -601,24 +593,54 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedAcceptInvitationIdRouteImport parentRoute: typeof LayoutAuthenticatedRoute } + '/_layout/_admin/admin/system': { + id: '/_layout/_admin/admin/system' + path: '/system' + fullPath: '/admin/system' + preLoaderRoute: typeof LayoutAdminAdminSystemRouteImport + parentRoute: typeof LayoutAdminAdminRoute + } + '/_layout/_public/apps/$accountId/': { + id: '/_layout/_public/apps/$accountId/' + path: '/apps/$accountId' + fullPath: '/apps/$accountId/' + preLoaderRoute: typeof LayoutPublicAppsAccountIdIndexRouteImport + parentRoute: typeof LayoutPublicRoute + } + '/_layout/_public/apps/$accountId/$gatewayId': { + id: '/_layout/_public/apps/$accountId/$gatewayId' + path: '/apps/$accountId/$gatewayId' + fullPath: '/apps/$accountId/$gatewayId' + preLoaderRoute: typeof LayoutPublicAppsAccountIdGatewayIdRouteImport + parentRoute: typeof LayoutPublicRoute + } } } -interface LayoutAuthenticatedAdminRouteChildren { - LayoutAuthenticatedAdminSystemRoute: typeof LayoutAuthenticatedAdminSystemRoute - LayoutAuthenticatedAdminIndexRoute: typeof LayoutAuthenticatedAdminIndexRoute +interface LayoutAdminAdminRouteChildren { + LayoutAdminAdminSystemRoute: typeof LayoutAdminAdminSystemRoute + LayoutAdminAdminIndexRoute: typeof LayoutAdminAdminIndexRoute } -const LayoutAuthenticatedAdminRouteChildren: LayoutAuthenticatedAdminRouteChildren = - { - LayoutAuthenticatedAdminSystemRoute: LayoutAuthenticatedAdminSystemRoute, - LayoutAuthenticatedAdminIndexRoute: LayoutAuthenticatedAdminIndexRoute, - } +const LayoutAdminAdminRouteChildren: LayoutAdminAdminRouteChildren = { + LayoutAdminAdminSystemRoute: LayoutAdminAdminSystemRoute, + LayoutAdminAdminIndexRoute: LayoutAdminAdminIndexRoute, +} -const LayoutAuthenticatedAdminRouteWithChildren = - LayoutAuthenticatedAdminRoute._addFileChildren( - LayoutAuthenticatedAdminRouteChildren, - ) +const LayoutAdminAdminRouteWithChildren = + LayoutAdminAdminRoute._addFileChildren(LayoutAdminAdminRouteChildren) + +interface LayoutAdminRouteChildren { + LayoutAdminAdminRoute: typeof LayoutAdminAdminRouteWithChildren +} + +const LayoutAdminRouteChildren: LayoutAdminRouteChildren = { + LayoutAdminAdminRoute: LayoutAdminAdminRouteWithChildren, +} + +const LayoutAdminRouteWithChildren = LayoutAdminRoute._addFileChildren( + LayoutAdminRouteChildren, +) interface LayoutAuthenticatedSettingsRouteChildren { LayoutAuthenticatedSettingsAuthMethodsRoute: typeof LayoutAuthenticatedSettingsAuthMethodsRoute @@ -645,8 +667,7 @@ const LayoutAuthenticatedSettingsRouteWithChildren = ) interface LayoutAuthenticatedRouteChildren { - LayoutAuthenticatedAdminRoute: typeof LayoutAuthenticatedAdminRouteWithChildren - LayoutAuthenticatedHomeRoute: typeof LayoutAuthenticatedHomeRoute + LayoutAuthenticatedDashboardRoute: typeof LayoutAuthenticatedDashboardRoute LayoutAuthenticatedSettingsRoute: typeof LayoutAuthenticatedSettingsRouteWithChildren LayoutAuthenticatedAcceptInvitationIdRoute: typeof LayoutAuthenticatedAcceptInvitationIdRoute LayoutAuthenticatedOrganizationsSlugRoute: typeof LayoutAuthenticatedOrganizationsSlugRoute @@ -658,8 +679,7 @@ interface LayoutAuthenticatedRouteChildren { } const LayoutAuthenticatedRouteChildren: LayoutAuthenticatedRouteChildren = { - LayoutAuthenticatedAdminRoute: LayoutAuthenticatedAdminRouteWithChildren, - LayoutAuthenticatedHomeRoute: LayoutAuthenticatedHomeRoute, + LayoutAuthenticatedDashboardRoute: LayoutAuthenticatedDashboardRoute, LayoutAuthenticatedSettingsRoute: LayoutAuthenticatedSettingsRouteWithChildren, LayoutAuthenticatedAcceptInvitationIdRoute: @@ -685,6 +705,12 @@ interface LayoutPublicRouteChildren { LayoutPublicLoginRoute: typeof LayoutPublicLoginRoute LayoutPublicSkillRoute: typeof LayoutPublicSkillRoute LayoutPublicIndexRoute: typeof LayoutPublicIndexRoute + LayoutPublicThingsThingIdRoute: typeof LayoutPublicThingsThingIdRoute + LayoutPublicThingsLiveRoute: typeof LayoutPublicThingsLiveRoute + LayoutPublicAppsIndexRoute: typeof LayoutPublicAppsIndexRoute + LayoutPublicThingsIndexRoute: typeof LayoutPublicThingsIndexRoute + LayoutPublicAppsAccountIdGatewayIdRoute: typeof LayoutPublicAppsAccountIdGatewayIdRoute + LayoutPublicAppsAccountIdIndexRoute: typeof LayoutPublicAppsAccountIdIndexRoute } const LayoutPublicRouteChildren: LayoutPublicRouteChildren = { @@ -693,6 +719,13 @@ const LayoutPublicRouteChildren: LayoutPublicRouteChildren = { LayoutPublicLoginRoute: LayoutPublicLoginRoute, LayoutPublicSkillRoute: LayoutPublicSkillRoute, LayoutPublicIndexRoute: LayoutPublicIndexRoute, + LayoutPublicThingsThingIdRoute: LayoutPublicThingsThingIdRoute, + LayoutPublicThingsLiveRoute: LayoutPublicThingsLiveRoute, + LayoutPublicAppsIndexRoute: LayoutPublicAppsIndexRoute, + LayoutPublicThingsIndexRoute: LayoutPublicThingsIndexRoute, + LayoutPublicAppsAccountIdGatewayIdRoute: + LayoutPublicAppsAccountIdGatewayIdRoute, + LayoutPublicAppsAccountIdIndexRoute: LayoutPublicAppsAccountIdIndexRoute, } const LayoutPublicRouteWithChildren = LayoutPublicRoute._addFileChildren( @@ -700,25 +733,15 @@ const LayoutPublicRouteWithChildren = LayoutPublicRoute._addFileChildren( ) interface LayoutRouteChildren { + LayoutAdminRoute: typeof LayoutAdminRouteWithChildren LayoutAuthenticatedRoute: typeof LayoutAuthenticatedRouteWithChildren LayoutPublicRoute: typeof LayoutPublicRouteWithChildren - LayoutThingsThingIdRoute: typeof LayoutThingsThingIdRoute - LayoutThingsLiveRoute: typeof LayoutThingsLiveRoute - LayoutAppsIndexRoute: typeof LayoutAppsIndexRoute - LayoutThingsIndexRoute: typeof LayoutThingsIndexRoute - LayoutAppsAccountIdGatewayIdRoute: typeof LayoutAppsAccountIdGatewayIdRoute - LayoutAppsAccountIdIndexRoute: typeof LayoutAppsAccountIdIndexRoute } const LayoutRouteChildren: LayoutRouteChildren = { + LayoutAdminRoute: LayoutAdminRouteWithChildren, LayoutAuthenticatedRoute: LayoutAuthenticatedRouteWithChildren, LayoutPublicRoute: LayoutPublicRouteWithChildren, - LayoutThingsThingIdRoute: LayoutThingsThingIdRoute, - LayoutThingsLiveRoute: LayoutThingsLiveRoute, - LayoutAppsIndexRoute: LayoutAppsIndexRoute, - LayoutThingsIndexRoute: LayoutThingsIndexRoute, - LayoutAppsAccountIdGatewayIdRoute: LayoutAppsAccountIdGatewayIdRoute, - LayoutAppsAccountIdIndexRoute: LayoutAppsAccountIdIndexRoute, } const LayoutRouteWithChildren = diff --git a/ui/src/routes/_layout/_admin.tsx b/ui/src/routes/_layout/_admin.tsx new file mode 100644 index 00000000..9906591e --- /dev/null +++ b/ui/src/routes/_layout/_admin.tsx @@ -0,0 +1,62 @@ +import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; +import type { SessionData } from "@/app"; +import { sessionQueryOptions } from "@/app"; + +interface AuthContext { + isAuthenticated: boolean; + user: SessionData["user"] | null; + session: SessionData["session"] | null; + activeOrganizationId: string | null; + isAnonymous: boolean; + isAdmin: boolean; + isBanned: boolean; +} + +export const Route = createFileRoute("/_layout/_admin")({ + beforeLoad: async ({ context, location }) => { + const { queryClient, authClient } = context; + + const session = await queryClient.ensureQueryData( + sessionQueryOptions(authClient, context.session), + ); + + if (!session?.user) { + throw redirect({ + to: "/login", + search: { + redirect: location.href, + }, + }); + } + + if (session.user.banned) { + throw redirect({ + to: "/login", + hash: "banned", + }); + } + + if (session.user.role !== "admin") { + throw redirect({ to: "/dashboard" }); + } + + const auth: AuthContext = { + isAuthenticated: true, + user: session.user, + session: session.session, + activeOrganizationId: session.session?.activeOrganizationId || null, + isAnonymous: session.user.isAnonymous || false, + isAdmin: session.user.role === "admin", + isBanned: session.user.banned || false, + }; + return { + auth, + session, + }; + }, + component: AdminGate, +}); + +function AdminGate() { + return <Outlet />; +} diff --git a/ui/src/routes/_layout/_authenticated/admin.tsx b/ui/src/routes/_layout/_admin/admin.tsx similarity index 65% rename from ui/src/routes/_layout/_authenticated/admin.tsx rename to ui/src/routes/_layout/_admin/admin.tsx index a044a4f4..e57fd806 100644 --- a/ui/src/routes/_layout/_authenticated/admin.tsx +++ b/ui/src/routes/_layout/_admin/admin.tsx @@ -1,12 +1,10 @@ -import { createFileRoute, Link, redirect } from "@tanstack/react-router"; -import { Shield, Users } from "lucide-react"; +import { createFileRoute, Link, Outlet, redirect } from "@tanstack/react-router"; +import { Shield } from "lucide-react"; import { getAccount } from "@/app"; -import { Card } from "@/components"; import { EmptyState } from "@/components/empty-state"; import { PageContainer } from "@/components/layout/page-container"; -import { InfoRow } from "@/components/ui/info-row"; -export const Route = createFileRoute("/_layout/_authenticated/admin")({ +export const Route = createFileRoute("/_layout/_admin/admin")({ head: () => ({ meta: [{ title: "Admin | app" }], }), @@ -109,42 +107,7 @@ function AdminPage() { /> </section> - <section className="space-y-3"> - <SectionHeader title="Tenant details" /> - <Card className="p-6 space-y-4"> - <div className="text-muted-foreground text-[11px] font-bold uppercase tracking-wider"> - Configuration - </div> - <div className="flex flex-col gap-2"> - <InfoRow label="name" value={tenant.name} /> - <InfoRow label="subdomain" value={tenant.subdomain} mono /> - <InfoRow label="account" value={tenant.accountId} mono /> - <InfoRow label="org Id" value={tenant.orgId} mono /> - <InfoRow - label="created" - value={tenant.createdAt ? new Date(tenant.createdAt).toLocaleDateString() : "—"} - /> - </div> - </Card> - </section> - - <section className="space-y-3"> - <SectionHeader title="Members & permissions" /> - <Card className="p-4 space-y-3"> - <p className="text-sm text-muted-foreground"> - This tenant is backed by an organization. Manage members, roles, and invitations - there. - </p> - <Link - to="/organizations/$slug" - params={{ slug: tenant.subdomain }} - className="h-9 px-3 inline-flex items-center gap-1.5 text-xs font-medium border-2 border-outset border-border-strong bg-card text-foreground shadow-sm hover:shadow-md active:border-inset active:shadow-none transition-all duration-200 ease-out rounded-[10px]" - > - <Users className="h-3.5 w-3.5" /> - open organization - </Link> - </Card> - </section> + <Outlet /> </div> </PageContainer> ); @@ -172,12 +135,3 @@ function StatCard({ </div> ); } - -function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) { - return ( - <div className="flex items-end justify-between gap-3"> - <h2 className="text-lg font-semibold text-foreground">{title}</h2> - {action} - </div> - ); -} diff --git a/ui/src/routes/_layout/_admin/admin/index.tsx b/ui/src/routes/_layout/_admin/admin/index.tsx new file mode 100644 index 00000000..fd5a7378 --- /dev/null +++ b/ui/src/routes/_layout/_admin/admin/index.tsx @@ -0,0 +1,152 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { Building2, Settings, Users } from "lucide-react"; +import { getAccount } from "@/app"; +import { Button, Card } from "@/components"; +import { InfoRow } from "@/components/ui/info-row"; + +export const Route = createFileRoute("/_layout/_admin/admin/")({ + head: () => ({ + meta: [{ title: "Admin Dashboard | app" }], + }), + component: AdminDashboard, +}); + +function AdminDashboard() { + const { auth, tenant } = Route.useRouteContext(); + const account = getAccount(); + const user = auth?.user ?? null; + + return ( + <div className="space-y-8"> + <header className="space-y-3"> + <div className="flex flex-wrap items-end justify-between gap-3"> + <div className="space-y-1"> + <h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-foreground"> + Dashboard + </h1> + <p className="text-sm text-muted-foreground"> + Signed in as <span className="font-mono">{account}</span> + </p> + </div> + </div> + </header> + + <section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> + <StatCard label="Account" value={account} mono /> + <StatCard label="Name" value={user?.name || user?.email || "—"} /> + <StatCard label="Role" value={user?.role ?? "—"} /> + <StatCard + label="Created" + value={user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : "—"} + /> + </section> + + <section className="space-y-3"> + <h2 className="text-lg font-semibold text-foreground">Manage</h2> + <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> + <Card className="p-6 space-y-3"> + <div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-foreground text-background"> + <Building2 className="h-4 w-4" /> + </div> + <h3 className="text-base font-semibold text-foreground">Organizations</h3> + <p className="text-sm text-muted-foreground"> + Manage organizations, members, roles, and invitations. + </p> + <Button asChild variant="outline" size="sm"> + <Link to="/organizations"> + <Users className="h-3.5 w-3.5" /> + open organizations + </Link> + </Button> + </Card> + + <Card className="p-6 space-y-3"> + <div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-foreground text-background"> + <Settings className="h-4 w-4" /> + </div> + <h3 className="text-base font-semibold text-foreground">Settings</h3> + <p className="text-sm text-muted-foreground"> + Update your profile, auth methods, and security preferences. + </p> + <Button asChild variant="outline" size="sm"> + <Link to="/settings">open settings</Link> + </Button> + </Card> + </div> + </section> + + {tenant && ( + <section className="space-y-3"> + <SectionHeader title="Tenant details" /> + <Card className="p-6 space-y-4"> + <div className="text-muted-foreground text-[11px] font-bold uppercase tracking-wider"> + Configuration + </div> + <div className="flex flex-col gap-2"> + <InfoRow label="name" value={tenant.name} /> + <InfoRow label="subdomain" value={tenant.subdomain} mono /> + <InfoRow label="account" value={tenant.accountId} mono /> + <InfoRow label="org Id" value={tenant.orgId} mono /> + <InfoRow + label="created" + value={tenant.createdAt ? new Date(tenant.createdAt).toLocaleDateString() : "—"} + /> + </div> + </Card> + </section> + )} + + {tenant && ( + <section className="space-y-3"> + <SectionHeader title="Members & permissions" /> + <Card className="p-4 space-y-3"> + <p className="text-sm text-muted-foreground"> + This tenant is backed by an organization. Manage members, roles, and invitations + there. + </p> + <Link + to="/organizations/$slug" + params={{ slug: tenant.subdomain }} + className="h-9 px-3 inline-flex items-center gap-1.5 text-xs font-medium border-2 border-outset border-border-strong bg-card text-foreground shadow-sm hover:shadow-md active:border-inset active:shadow-none transition-all duration-200 ease-out rounded-[10px]" + > + <Users className="h-3.5 w-3.5" /> + open organization + </Link> + </Card> + </section> + )} + </div> + ); +} + +function StatCard({ + label, + value, + mono, +}: { + label: string; + value: React.ReactNode; + mono?: boolean; +}) { + return ( + <div className="border-2 border-outset border-border-strong bg-card p-4 rounded-[12px] shadow-sm space-y-1"> + <div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> + {label} + </div> + <div + className={`text-sm text-foreground break-all ${mono ? "font-mono text-xs" : "font-semibold"}`} + > + {value} + </div> + </div> + ); +} + +function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) { + return ( + <div className="flex items-end justify-between gap-3"> + <h2 className="text-lg font-semibold text-foreground">{title}</h2> + {action} + </div> + ); +} diff --git a/ui/src/routes/_layout/_admin/admin/system.tsx b/ui/src/routes/_layout/_admin/admin/system.tsx new file mode 100644 index 00000000..a64457a3 --- /dev/null +++ b/ui/src/routes/_layout/_admin/admin/system.tsx @@ -0,0 +1,70 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { getAccount, getActiveRuntime, getAppName, getRepository } from "@/app"; +import { Card } from "@/components"; +import { InfoRow } from "@/components/ui/info-row"; + +export const Route = createFileRoute("/_layout/_admin/admin/system")({ + loader: async ({ context }) => ({ + runtimeConfig: context.runtimeConfig, + }), + head: () => ({ + meta: [{ title: "Admin System | app" }], + }), + component: AdminSystem, +}); + +function AdminSystem() { + const { runtimeConfig } = Route.useLoaderData(); + const account = getAccount(runtimeConfig); + const appName = getAppName(runtimeConfig); + const repository = getRepository(runtimeConfig); + const runtime = getActiveRuntime(runtimeConfig); + + const env = runtimeConfig?.env; + const networkId = runtimeConfig?.networkId; + const hostUrl = runtimeConfig?.hostUrl; + const apiBase = runtimeConfig?.apiBase; + const rpcBase = runtimeConfig?.rpcBase; + const assetsUrl = runtimeConfig?.assetsUrl; + const runtimeBasePath = runtime?.runtimeBasePath; + + return ( + <div className="space-y-6"> + <header className="space-y-3"> + <div className="space-y-1"> + <h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-foreground"> + System + </h1> + <p className="text-sm text-muted-foreground"> + Runtime configuration for this deployment. + </p> + </div> + </header> + + <div className="grid gap-4 sm:grid-cols-2"> + <Card className="p-6 space-y-4"> + <h2 className="text-sm font-semibold text-foreground">Runtime</h2> + <InfoRow label="account" value={runtime?.accountId ?? account} mono /> + <InfoRow label="name" value={appName} /> + <InfoRow label="base path" value={runtimeBasePath ?? "/"} mono /> + <InfoRow label="gateway" value={runtime?.gatewayId} mono /> + </Card> + + <Card className="p-6 space-y-4"> + <h2 className="text-sm font-semibold text-foreground">Deployment</h2> + <InfoRow label="env" value={env ?? "—"} mono /> + <InfoRow label="network" value={networkId ?? "—"} mono /> + <InfoRow label="host" value={hostUrl ?? "—"} mono /> + <InfoRow label="repository" value={repository} mono /> + </Card> + + <Card className="p-6 space-y-4"> + <h2 className="text-sm font-semibold text-foreground">Endpoints</h2> + <InfoRow label="api" value={apiBase} mono /> + <InfoRow label="rpc" value={rpcBase} mono /> + <InfoRow label="assets" value={assetsUrl} mono /> + </Card> + </div> + </div> + ); +} diff --git a/ui/src/routes/_layout/_authenticated.tsx b/ui/src/routes/_layout/_authenticated.tsx index 869f23e3..72eae14a 100644 --- a/ui/src/routes/_layout/_authenticated.tsx +++ b/ui/src/routes/_layout/_authenticated.tsx @@ -94,7 +94,7 @@ function AuthenticatedLayout() { const isAdmin = session?.user?.role === "admin"; const sidebarItems: SidebarItem[] = [ - { icon: Home, label: "home", to: "/home", roleRequired: "anon" }, + { icon: Home, label: "dashboard", to: "/dashboard", roleRequired: "anon" }, { icon: Shield, label: "admin", to: "/admin", roleRequired: "admin" }, { icon: MessageSquare, label: "nostr", to: "/nostr", roleRequired: "admin" }, ]; @@ -221,7 +221,7 @@ function MobileTabBar({ style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }} > <div className="flex items-center justify-around px-2 py-1"> - <TabItem to="/home" icon={Home} label="home" active={tabActive("/home")} /> + <TabItem to="/dashboard" icon={Home} label="dashboard" active={tabActive("/dashboard")} /> <TabItem to="/apps" icon={Globe} label="apps" active={tabActive("/apps")} /> <TabItem to="/explore" icon={Compass} label="explore" active={tabActive("/explore")} /> <TabItem to="/recent" icon={ClipboardList} label="recent" active={tabActive("/recent")} /> diff --git a/ui/src/routes/_layout/_authenticated/admin/index.tsx b/ui/src/routes/_layout/_authenticated/admin/index.tsx deleted file mode 100644 index 65c3c2d3..00000000 --- a/ui/src/routes/_layout/_authenticated/admin/index.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; -import { Building2, Settings, Users } from "lucide-react"; -import { getAccount } from "@/app"; -import { Button, Card } from "@/components"; -import { PageContainer } from "@/components/layout/page-container"; - -export const Route = createFileRoute("/_layout/_authenticated/admin/")({ - head: () => ({ - meta: [{ title: "Admin Dashboard | app" }], - }), - component: AdminDashboard, -}); - -function AdminDashboard() { - const { auth } = Route.useRouteContext(); - const account = getAccount(); - const user = auth?.user ?? null; - - return ( - <PageContainer variant="wide"> - <div className="space-y-8"> - <header className="space-y-3"> - <div className="flex flex-wrap items-end justify-between gap-3"> - <div className="space-y-1"> - <h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-foreground"> - Dashboard - </h1> - <p className="text-sm text-muted-foreground"> - Signed in as <span className="font-mono">{account}</span> - </p> - </div> - </div> - </header> - - <section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> - <StatCard label="Account" value={account} mono /> - <StatCard label="Name" value={user?.name || user?.email || "—"} /> - <StatCard label="Role" value={user?.role ?? "—"} /> - <StatCard - label="Created" - value={user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : "—"} - /> - </section> - - <section className="space-y-3"> - <h2 className="text-lg font-semibold text-foreground">Manage</h2> - <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> - <Card className="p-6 space-y-3"> - <div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-foreground text-background"> - <Building2 className="h-4 w-4" /> - </div> - <h3 className="text-base font-semibold text-foreground">Organizations</h3> - <p className="text-sm text-muted-foreground"> - Manage organizations, members, roles, and invitations. - </p> - <Button asChild variant="outline" size="sm"> - <Link to="/organizations"> - <Users className="h-3.5 w-3.5" /> - open organizations - </Link> - </Button> - </Card> - - <Card className="p-6 space-y-3"> - <div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-foreground text-background"> - <Settings className="h-4 w-4" /> - </div> - <h3 className="text-base font-semibold text-foreground">Settings</h3> - <p className="text-sm text-muted-foreground"> - Update your profile, auth methods, and security preferences. - </p> - <Button asChild variant="outline" size="sm"> - <Link to="/settings">open settings</Link> - </Button> - </Card> - </div> - </section> - </div> - </PageContainer> - ); -} - -function StatCard({ - label, - value, - mono, -}: { - label: string; - value: React.ReactNode; - mono?: boolean; -}) { - return ( - <div className="border-2 border-outset border-border-strong bg-card p-4 rounded-[12px] shadow-sm space-y-1"> - <div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> - {label} - </div> - <div - className={`text-sm text-foreground break-all ${mono ? "font-mono text-xs" : "font-semibold"}`} - > - {value} - </div> - </div> - ); -} diff --git a/ui/src/routes/_layout/_authenticated/admin/system.tsx b/ui/src/routes/_layout/_authenticated/admin/system.tsx deleted file mode 100644 index a98296af..00000000 --- a/ui/src/routes/_layout/_authenticated/admin/system.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { getAccount, getActiveRuntime, getAppName, getRepository } from "@/app"; -import { Card } from "@/components"; -import { PageContainer } from "@/components/layout/page-container"; -import { InfoRow } from "@/components/ui/info-row"; - -export const Route = createFileRoute("/_layout/_authenticated/admin/system")({ - loader: async ({ context }) => ({ - runtimeConfig: context.runtimeConfig, - }), - head: () => ({ - meta: [{ title: "Admin System | app" }], - }), - component: AdminSystem, -}); - -function AdminSystem() { - const { runtimeConfig } = Route.useLoaderData(); - const account = getAccount(runtimeConfig); - const appName = getAppName(runtimeConfig); - const repository = getRepository(runtimeConfig); - const runtime = getActiveRuntime(runtimeConfig); - - const env = runtimeConfig?.env; - const networkId = runtimeConfig?.networkId; - const hostUrl = runtimeConfig?.hostUrl; - const apiBase = runtimeConfig?.apiBase; - const rpcBase = runtimeConfig?.rpcBase; - const assetsUrl = runtimeConfig?.assetsUrl; - const runtimeBasePath = runtime?.runtimeBasePath; - - return ( - <PageContainer variant="default"> - <div className="space-y-6"> - <header className="space-y-3"> - <div className="space-y-1"> - <h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-foreground"> - System - </h1> - <p className="text-sm text-muted-foreground"> - Runtime configuration for this deployment. - </p> - </div> - </header> - - <div className="grid gap-4 sm:grid-cols-2"> - <Card className="p-6 space-y-4"> - <h2 className="text-sm font-semibold text-foreground">Runtime</h2> - <InfoRow label="account" value={runtime?.accountId ?? account} mono /> - <InfoRow label="name" value={appName} /> - <InfoRow label="base path" value={runtimeBasePath ?? "/"} mono /> - <InfoRow label="gateway" value={runtime?.gatewayId} mono /> - </Card> - - <Card className="p-6 space-y-4"> - <h2 className="text-sm font-semibold text-foreground">Deployment</h2> - <InfoRow label="env" value={env ?? "—"} mono /> - <InfoRow label="network" value={networkId ?? "—"} mono /> - <InfoRow label="host" value={hostUrl ?? "—"} mono /> - <InfoRow label="repository" value={repository} mono /> - </Card> - - <Card className="p-6 space-y-4"> - <h2 className="text-sm font-semibold text-foreground">Endpoints</h2> - <InfoRow label="api" value={apiBase} mono /> - <InfoRow label="rpc" value={rpcBase} mono /> - <InfoRow label="assets" value={assetsUrl} mono /> - </Card> - </div> - </div> - </PageContainer> - ); -} diff --git a/ui/src/routes/_layout/_authenticated/home.tsx b/ui/src/routes/_layout/_authenticated/dashboard.tsx similarity index 98% rename from ui/src/routes/_layout/_authenticated/home.tsx rename to ui/src/routes/_layout/_authenticated/dashboard.tsx index 1fccff0e..86f23a6c 100644 --- a/ui/src/routes/_layout/_authenticated/home.tsx +++ b/ui/src/routes/_layout/_authenticated/dashboard.tsx @@ -13,7 +13,7 @@ import { Card } from "@/components"; import { PageContainer } from "@/components/layout/page-container"; import { InfoRow } from "@/components/ui/info-row"; -export const Route = createFileRoute("/_layout/_authenticated/home")({ +export const Route = createFileRoute("/_layout/_authenticated/dashboard")({ beforeLoad: async ({ context }) => { const { apiClient, runtimeConfig } = context; const accountId = getAccount(runtimeConfig); diff --git a/ui/src/routes/_layout/apps/$accountId/$gatewayId.tsx b/ui/src/routes/_layout/_public/apps/$accountId/$gatewayId.tsx similarity index 99% rename from ui/src/routes/_layout/apps/$accountId/$gatewayId.tsx rename to ui/src/routes/_layout/_public/apps/$accountId/$gatewayId.tsx index 279e4782..52984590 100644 --- a/ui/src/routes/_layout/apps/$accountId/$gatewayId.tsx +++ b/ui/src/routes/_layout/_public/apps/$accountId/$gatewayId.tsx @@ -19,7 +19,7 @@ const getStartCommand = (accountId: string, gatewayId: string) => const getExtendsCommand = (accountId: string, gatewayId: string) => `bunx everything-dev@latest init --extends bos://${accountId}/${gatewayId}`; -export const Route = createFileRoute("/_layout/apps/$accountId/$gatewayId")({ +export const Route = createFileRoute("/_layout/_public/apps/$accountId/$gatewayId")({ loader: async ({ params, context }) => { const { queryClient, apiClient } = context; await queryClient.prefetchQuery({ diff --git a/ui/src/routes/_layout/apps/$accountId/index.tsx b/ui/src/routes/_layout/_public/apps/$accountId/index.tsx similarity index 99% rename from ui/src/routes/_layout/apps/$accountId/index.tsx rename to ui/src/routes/_layout/_public/apps/$accountId/index.tsx index 904bdaf7..0ec1eb61 100644 --- a/ui/src/routes/_layout/apps/$accountId/index.tsx +++ b/ui/src/routes/_layout/_public/apps/$accountId/index.tsx @@ -9,7 +9,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; const BASE_RUNTIME = "bos://dev.everything.near/everything.dev"; -export const Route = createFileRoute("/_layout/apps/$accountId/")({ +export const Route = createFileRoute("/_layout/_public/apps/$accountId/")({ loader: async ({ params, context }) => { const { queryClient, apiClient } = context; await queryClient.prefetchQuery({ diff --git a/ui/src/routes/_layout/apps/index.tsx b/ui/src/routes/_layout/_public/apps/index.tsx similarity index 99% rename from ui/src/routes/_layout/apps/index.tsx rename to ui/src/routes/_layout/_public/apps/index.tsx index 8ea8263f..4ce3c601 100644 --- a/ui/src/routes/_layout/apps/index.tsx +++ b/ui/src/routes/_layout/_public/apps/index.tsx @@ -21,7 +21,7 @@ type SearchParams = { preview?: string; }; -export const Route = createFileRoute("/_layout/apps/")({ +export const Route = createFileRoute("/_layout/_public/apps/")({ validateSearch: (search: Record<string, unknown>): SearchParams => ({ preview: typeof search.preview === "string" && search.preview.length > 0 ? search.preview : undefined, diff --git a/ui/src/routes/_layout/_public/login.tsx b/ui/src/routes/_layout/_public/login.tsx index fdc771d9..abc68a82 100644 --- a/ui/src/routes/_layout/_public/login.tsx +++ b/ui/src/routes/_layout/_public/login.tsx @@ -24,7 +24,7 @@ export const Route = createFileRoute("/_layout/_public/login")({ queryClient.getQueryData(sessionQueryOptions(authClient, initialSession).queryKey); if (session?.user) { - const redirectTo = search.redirect?.startsWith("/") ? search.redirect : "/home"; + const redirectTo = search.redirect?.startsWith("/") ? search.redirect : "/dashboard"; throw redirect({ to: redirectTo, search: {} }); } }, @@ -57,7 +57,7 @@ function LoginPage() { }, [auth.near]); const handleSuccess = async (message: string) => { - const redirectTo = redirect?.startsWith("/") ? redirect : "/home"; + const redirectTo = redirect?.startsWith("/") ? redirect : "/dashboard"; toast.success(message); queryClient.invalidateQueries({ queryKey: ["session"] }); navigate({ to: redirectTo, replace: true, search: {} }); @@ -110,7 +110,7 @@ function LoginPage() { }; if (session?.user) { - const redirectTo = redirect?.startsWith("/") ? redirect : "/home"; + const redirectTo = redirect?.startsWith("/") ? redirect : "/dashboard"; return <Navigate to={redirectTo} replace search={{}} />; } diff --git a/ui/src/routes/_layout/things/$thingId.tsx b/ui/src/routes/_layout/_public/things/$thingId.tsx similarity index 98% rename from ui/src/routes/_layout/things/$thingId.tsx rename to ui/src/routes/_layout/_public/things/$thingId.tsx index 0fdb7405..ac3184f4 100644 --- a/ui/src/routes/_layout/things/$thingId.tsx +++ b/ui/src/routes/_layout/_public/things/$thingId.tsx @@ -6,7 +6,7 @@ import { sessionQueryOptions, useApiClient, useAuthClient } from "@/app"; import { Badge, Button } from "@/components"; import { Skeleton } from "@/components/ui/skeleton"; -export const Route = createFileRoute("/_layout/things/$thingId")({ +export const Route = createFileRoute("/_layout/_public/things/$thingId")({ head: ({ params }) => ({ meta: [ { title: `${params.thingId} | Things | everything.dev` }, diff --git a/ui/src/routes/_layout/things/index.tsx b/ui/src/routes/_layout/_public/things/index.tsx similarity index 97% rename from ui/src/routes/_layout/things/index.tsx rename to ui/src/routes/_layout/_public/things/index.tsx index 6cbee7e0..fba26c37 100644 --- a/ui/src/routes/_layout/things/index.tsx +++ b/ui/src/routes/_layout/_public/things/index.tsx @@ -1,7 +1,7 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; -export const Route = createFileRoute("/_layout/things/")({ +export const Route = createFileRoute("/_layout/_public/things/")({ head: () => ({ meta: [ { title: "Things | everything.dev" }, diff --git a/ui/src/routes/_layout/things/live.tsx b/ui/src/routes/_layout/_public/things/live.tsx similarity index 98% rename from ui/src/routes/_layout/things/live.tsx rename to ui/src/routes/_layout/_public/things/live.tsx index 7be8f0c6..674d7813 100644 --- a/ui/src/routes/_layout/things/live.tsx +++ b/ui/src/routes/_layout/_public/things/live.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useApiClient } from "@/app"; import { Badge } from "@/components"; -export const Route = createFileRoute("/_layout/things/live")({ +export const Route = createFileRoute("/_layout/_public/things/live")({ head: () => ({ meta: [ { title: "Live Stream | Things | everything.dev" }, From 9c90224a15ff2a60e38d7eade3f0f6e20f95ab15 Mon Sep 17 00:00:00 2001 From: Elliot Braem <elliot@ejlbraem.com> Date: Thu, 13 Aug 2026 17:44:55 -0500 Subject: [PATCH 06/12] test(regression): update /home references to /dashboard after route rename --- tests/regression/browser/helpers/seeded.ts | 2 +- tests/regression/browser/specs/auth-client.spec.ts | 4 ++-- tests/regression/browser/specs/auth-redirect.spec.ts | 4 ++-- tests/regression/browser/specs/logout.spec.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/regression/browser/helpers/seeded.ts b/tests/regression/browser/helpers/seeded.ts index 560e0aad..c580e4f9 100644 --- a/tests/regression/browser/helpers/seeded.ts +++ b/tests/regression/browser/helpers/seeded.ts @@ -42,7 +42,7 @@ export function loadSeedData(): SeedData { } export async function verifyAuthenticated(page: Page) { - await page.goto("/home", { waitUntil: "domcontentloaded" }); + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); await page.waitForTimeout(500); await page.waitForLoadState("networkidle"); await expect(page.locator("button[title='account menu']")).toBeVisible({ timeout: 10000 }); diff --git a/tests/regression/browser/specs/auth-client.spec.ts b/tests/regression/browser/specs/auth-client.spec.ts index bda4857d..970989f7 100644 --- a/tests/regression/browser/specs/auth-client.spec.ts +++ b/tests/regression/browser/specs/auth-client.spec.ts @@ -58,9 +58,9 @@ test.describe("authClient", () => { await anonymousBtn.click(); await signInDone; - await page.waitForURL(/\/home$/, { timeout: 15000 }); + await page.waitForURL(/\/dashboard$/, { timeout: 15000 }); await page.reload(); - await page.waitForURL(/\/home$/, { timeout: 15000 }); + await page.waitForURL(/\/dashboard$/, { timeout: 15000 }); await page.waitForLoadState("networkidle"); await waitForApp(page); diff --git a/tests/regression/browser/specs/auth-redirect.spec.ts b/tests/regression/browser/specs/auth-redirect.spec.ts index f0b3aede..afbaf19a 100644 --- a/tests/regression/browser/specs/auth-redirect.spec.ts +++ b/tests/regression/browser/specs/auth-redirect.spec.ts @@ -26,8 +26,8 @@ test.describe("Auth redirect", () => { expectNoHydrationFailure(pageErrors); }); - test("unauthenticated /home redirects to /login", async ({ page }) => { - await page.goto("/home", { waitUntil: "domcontentloaded" }); + test("unauthenticated /dashboard redirects to /login", async ({ page }) => { + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); await waitForApp(page); await page.waitForURL(/\/login/, { timeout: 15000 }); diff --git a/tests/regression/browser/specs/logout.spec.ts b/tests/regression/browser/specs/logout.spec.ts index cef410e9..97642bcd 100644 --- a/tests/regression/browser/specs/logout.spec.ts +++ b/tests/regression/browser/specs/logout.spec.ts @@ -23,9 +23,9 @@ test.describe("logout", () => { await anonymousBtn.click(); await signInDone; - await page.waitForURL(/\/home$/, { timeout: 15000 }); + await page.waitForURL(/\/dashboard$/, { timeout: 15000 }); await page.reload(); - await page.waitForURL(/\/home$/, { timeout: 15000 }); + await page.waitForURL(/\/dashboard$/, { timeout: 15000 }); await page.waitForLoadState("networkidle"); await waitForApp(page); await page.locator("button[title='account menu']").click(); From 7f1b9dc11e98cdd98a404914dcde0c2bbb0bffb2 Mon Sep 17 00:00:00 2001 From: Elliot Braem <elliot@ejlbraem.com> Date: Thu, 13 Aug 2026 17:56:16 -0500 Subject: [PATCH 07/12] refactor(ui): add _anon mount for login, rename organizations to orgs, remove nostr - move login from public layout into new _anon pathless layout that redirects authed users to /dashboard and provides theme toggle header - rename organization route group from /organizations to /orgs; move invitation acceptance to /orgs/invites/$id - remove stale nostr entry from authenticated sidebar - changeset: ui-anon-orgs --- .changeset/ui-anon-orgs.md | 9 + ui/src/components/org-switcher.tsx | 2 +- ui/src/components/user-nav.tsx | 2 +- ui/src/routeTree.gen.ts | 224 ++++++++++-------- ui/src/routes/_layout/_admin/admin.tsx | 4 +- ui/src/routes/_layout/_admin/admin/index.tsx | 4 +- ui/src/routes/_layout/_anon.tsx | 57 +++++ .../_layout/{_public => _anon}/login.tsx | 4 +- ui/src/routes/_layout/_authenticated.tsx | 3 +- .../{organizations => orgs}/$slug.tsx | 12 +- .../{organizations => orgs}/index.tsx | 10 +- .../invites.$id.tsx} | 10 +- .../{organizations => orgs}/new.tsx | 10 +- .../_authenticated/tenant/$tenantId.tsx | 2 +- 14 files changed, 220 insertions(+), 133 deletions(-) create mode 100644 .changeset/ui-anon-orgs.md create mode 100644 ui/src/routes/_layout/_anon.tsx rename ui/src/routes/_layout/{_public => _anon}/login.tsx (98%) rename ui/src/routes/_layout/_authenticated/{organizations => orgs}/$slug.tsx (99%) rename ui/src/routes/_layout/_authenticated/{organizations => orgs}/index.tsx (98%) rename ui/src/routes/_layout/_authenticated/{accept-invitation.$id.tsx => orgs/invites.$id.tsx} (95%) rename ui/src/routes/_layout/_authenticated/{organizations => orgs}/new.tsx (96%) diff --git a/.changeset/ui-anon-orgs.md b/.changeset/ui-anon-orgs.md new file mode 100644 index 00000000..d1e2a805 --- /dev/null +++ b/.changeset/ui-anon-orgs.md @@ -0,0 +1,9 @@ +--- +"ui": minor +--- + +Add a dedicated anonymous mount and reorganize organization routes. + +- Add a `/_layout/_anon` pathless layout for pre-auth pages. Move login from the public layout into it; the layout redirects authenticated users to `/dashboard` and provides the theme toggle header. +- Rename the organization route group from `/organizations` to `/orgs` (`/orgs`, `/orgs/new`, `/orgs/$slug`) and move invitation acceptance to `/orgs/invites/$id`. +- Remove the stale nostr entry from the authenticated sidebar. \ No newline at end of file diff --git a/ui/src/components/org-switcher.tsx b/ui/src/components/org-switcher.tsx index 8d4eada0..7760d57f 100644 --- a/ui/src/components/org-switcher.tsx +++ b/ui/src/components/org-switcher.tsx @@ -64,7 +64,7 @@ export function OrgSwitcher({ organizations, activeOrgId, onSwitch }: OrgSwitche )} <DropdownMenuSeparator /> <DropdownMenuItem asChild> - <Link to="/organizations/new" className="flex items-center gap-2 cursor-pointer"> + <Link to="/orgs/new" className="flex items-center gap-2 cursor-pointer"> <Plus className="h-3.5 w-3.5" /> new organization </Link> diff --git a/ui/src/components/user-nav.tsx b/ui/src/components/user-nav.tsx index ba6a6107..084aca2f 100644 --- a/ui/src/components/user-nav.tsx +++ b/ui/src/components/user-nav.tsx @@ -151,7 +151,7 @@ export function UserNav() { </DropdownMenuItem> {activeOrg && ( <DropdownMenuItem asChild> - <Link to="/organizations/$slug" params={{ slug: activeOrg.slug }}> + <Link to="/orgs/$slug" params={{ slug: activeOrg.slug }}> <Building2 /> {activeOrg.name} </Link> diff --git a/ui/src/routeTree.gen.ts b/ui/src/routeTree.gen.ts index 359bd53d..4ad07bcf 100644 --- a/ui/src/routeTree.gen.ts +++ b/ui/src/routeTree.gen.ts @@ -12,19 +12,20 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutPublicRouteImport } from './routes/_layout/_public' import { Route as LayoutAuthenticatedRouteImport } from './routes/_layout/_authenticated' +import { Route as LayoutAnonRouteImport } from './routes/_layout/_anon' import { Route as LayoutAdminRouteImport } from './routes/_layout/_admin' import { Route as LayoutPublicIndexRouteImport } from './routes/_layout/_public/index' import { Route as LayoutPublicSkillRouteImport } from './routes/_layout/_public/skill' -import { Route as LayoutPublicLoginRouteImport } from './routes/_layout/_public/login' import { Route as LayoutPublicAboutRouteImport } from './routes/_layout/_public/about' import { Route as LayoutPublicAccountIdRouteImport } from './routes/_layout/_public/$accountId' import { Route as LayoutAuthenticatedSettingsRouteImport } from './routes/_layout/_authenticated/settings' import { Route as LayoutAuthenticatedDashboardRouteImport } from './routes/_layout/_authenticated/dashboard' +import { Route as LayoutAnonLoginRouteImport } from './routes/_layout/_anon/login' import { Route as LayoutAdminAdminRouteImport } from './routes/_layout/_admin/admin' import { Route as LayoutPublicThingsIndexRouteImport } from './routes/_layout/_public/things/index' import { Route as LayoutPublicAppsIndexRouteImport } from './routes/_layout/_public/apps/index' import { Route as LayoutAuthenticatedSettingsIndexRouteImport } from './routes/_layout/_authenticated/settings/index' -import { Route as LayoutAuthenticatedOrganizationsIndexRouteImport } from './routes/_layout/_authenticated/organizations/index' +import { Route as LayoutAuthenticatedOrgsIndexRouteImport } from './routes/_layout/_authenticated/orgs/index' import { Route as LayoutAdminAdminIndexRouteImport } from './routes/_layout/_admin/admin/index' import { Route as LayoutPublicThingsLiveRouteImport } from './routes/_layout/_public/things/live' import { Route as LayoutPublicThingsThingIdRouteImport } from './routes/_layout/_public/things/$thingId' @@ -34,12 +35,12 @@ import { Route as LayoutAuthenticatedTenantTenantIdRouteImport } from './routes/ import { Route as LayoutAuthenticatedSettingsSecurityRouteImport } from './routes/_layout/_authenticated/settings/security' import { Route as LayoutAuthenticatedSettingsProfileRouteImport } from './routes/_layout/_authenticated/settings/profile' import { Route as LayoutAuthenticatedSettingsAuthMethodsRouteImport } from './routes/_layout/_authenticated/settings/auth-methods' -import { Route as LayoutAuthenticatedOrganizationsNewRouteImport } from './routes/_layout/_authenticated/organizations/new' -import { Route as LayoutAuthenticatedOrganizationsSlugRouteImport } from './routes/_layout/_authenticated/organizations/$slug' -import { Route as LayoutAuthenticatedAcceptInvitationIdRouteImport } from './routes/_layout/_authenticated/accept-invitation.$id' +import { Route as LayoutAuthenticatedOrgsNewRouteImport } from './routes/_layout/_authenticated/orgs/new' +import { Route as LayoutAuthenticatedOrgsSlugRouteImport } from './routes/_layout/_authenticated/orgs/$slug' import { Route as LayoutAdminAdminSystemRouteImport } from './routes/_layout/_admin/admin/system' import { Route as LayoutPublicAppsAccountIdIndexRouteImport } from './routes/_layout/_public/apps/$accountId/index' import { Route as LayoutPublicAppsAccountIdGatewayIdRouteImport } from './routes/_layout/_public/apps/$accountId/$gatewayId' +import { Route as LayoutAuthenticatedOrgsInvitesIdRouteImport } from './routes/_layout/_authenticated/orgs/invites.$id' const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', @@ -53,6 +54,10 @@ const LayoutAuthenticatedRoute = LayoutAuthenticatedRouteImport.update({ id: '/_authenticated', getParentRoute: () => LayoutRoute, } as any) +const LayoutAnonRoute = LayoutAnonRouteImport.update({ + id: '/_anon', + getParentRoute: () => LayoutRoute, +} as any) const LayoutAdminRoute = LayoutAdminRouteImport.update({ id: '/_admin', getParentRoute: () => LayoutRoute, @@ -67,11 +72,6 @@ const LayoutPublicSkillRoute = LayoutPublicSkillRouteImport.update({ path: '/skill', getParentRoute: () => LayoutPublicRoute, } as any) -const LayoutPublicLoginRoute = LayoutPublicLoginRouteImport.update({ - id: '/login', - path: '/login', - getParentRoute: () => LayoutPublicRoute, -} as any) const LayoutPublicAboutRoute = LayoutPublicAboutRouteImport.update({ id: '/about', path: '/about', @@ -94,6 +94,11 @@ const LayoutAuthenticatedDashboardRoute = path: '/dashboard', getParentRoute: () => LayoutAuthenticatedRoute, } as any) +const LayoutAnonLoginRoute = LayoutAnonLoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => LayoutAnonRoute, +} as any) const LayoutAdminAdminRoute = LayoutAdminAdminRouteImport.update({ id: '/admin', path: '/admin', @@ -115,10 +120,10 @@ const LayoutAuthenticatedSettingsIndexRoute = path: '/', getParentRoute: () => LayoutAuthenticatedSettingsRoute, } as any) -const LayoutAuthenticatedOrganizationsIndexRoute = - LayoutAuthenticatedOrganizationsIndexRouteImport.update({ - id: '/organizations/', - path: '/organizations/', +const LayoutAuthenticatedOrgsIndexRoute = + LayoutAuthenticatedOrgsIndexRouteImport.update({ + id: '/orgs/', + path: '/orgs/', getParentRoute: () => LayoutAuthenticatedRoute, } as any) const LayoutAdminAdminIndexRoute = LayoutAdminAdminIndexRouteImport.update({ @@ -173,22 +178,16 @@ const LayoutAuthenticatedSettingsAuthMethodsRoute = path: '/auth-methods', getParentRoute: () => LayoutAuthenticatedSettingsRoute, } as any) -const LayoutAuthenticatedOrganizationsNewRoute = - LayoutAuthenticatedOrganizationsNewRouteImport.update({ - id: '/organizations/new', - path: '/organizations/new', +const LayoutAuthenticatedOrgsNewRoute = + LayoutAuthenticatedOrgsNewRouteImport.update({ + id: '/orgs/new', + path: '/orgs/new', getParentRoute: () => LayoutAuthenticatedRoute, } as any) -const LayoutAuthenticatedOrganizationsSlugRoute = - LayoutAuthenticatedOrganizationsSlugRouteImport.update({ - id: '/organizations/$slug', - path: '/organizations/$slug', - getParentRoute: () => LayoutAuthenticatedRoute, - } as any) -const LayoutAuthenticatedAcceptInvitationIdRoute = - LayoutAuthenticatedAcceptInvitationIdRouteImport.update({ - id: '/accept-invitation/$id', - path: '/accept-invitation/$id', +const LayoutAuthenticatedOrgsSlugRoute = + LayoutAuthenticatedOrgsSlugRouteImport.update({ + id: '/orgs/$slug', + path: '/orgs/$slug', getParentRoute: () => LayoutAuthenticatedRoute, } as any) const LayoutAdminAdminSystemRoute = LayoutAdminAdminSystemRouteImport.update({ @@ -208,20 +207,25 @@ const LayoutPublicAppsAccountIdGatewayIdRoute = path: '/apps/$accountId/$gatewayId', getParentRoute: () => LayoutPublicRoute, } as any) +const LayoutAuthenticatedOrgsInvitesIdRoute = + LayoutAuthenticatedOrgsInvitesIdRouteImport.update({ + id: '/orgs/invites/$id', + path: '/orgs/invites/$id', + getParentRoute: () => LayoutAuthenticatedRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof LayoutPublicIndexRoute '/admin': typeof LayoutAdminAdminRouteWithChildren + '/login': typeof LayoutAnonLoginRoute '/dashboard': typeof LayoutAuthenticatedDashboardRoute '/settings': typeof LayoutAuthenticatedSettingsRouteWithChildren '/$accountId': typeof LayoutPublicAccountIdRoute '/about': typeof LayoutPublicAboutRoute - '/login': typeof LayoutPublicLoginRoute '/skill': typeof LayoutPublicSkillRoute '/admin/system': typeof LayoutAdminAdminSystemRoute - '/accept-invitation/$id': typeof LayoutAuthenticatedAcceptInvitationIdRoute - '/organizations/$slug': typeof LayoutAuthenticatedOrganizationsSlugRoute - '/organizations/new': typeof LayoutAuthenticatedOrganizationsNewRoute + '/orgs/$slug': typeof LayoutAuthenticatedOrgsSlugRoute + '/orgs/new': typeof LayoutAuthenticatedOrgsNewRoute '/settings/auth-methods': typeof LayoutAuthenticatedSettingsAuthMethodsRoute '/settings/profile': typeof LayoutAuthenticatedSettingsProfileRoute '/settings/security': typeof LayoutAuthenticatedSettingsSecurityRoute @@ -231,24 +235,24 @@ export interface FileRoutesByFullPath { '/things/$thingId': typeof LayoutPublicThingsThingIdRoute '/things/live': typeof LayoutPublicThingsLiveRoute '/admin/': typeof LayoutAdminAdminIndexRoute - '/organizations/': typeof LayoutAuthenticatedOrganizationsIndexRoute + '/orgs/': typeof LayoutAuthenticatedOrgsIndexRoute '/settings/': typeof LayoutAuthenticatedSettingsIndexRoute '/apps/': typeof LayoutPublicAppsIndexRoute '/things/': typeof LayoutPublicThingsIndexRoute + '/orgs/invites/$id': typeof LayoutAuthenticatedOrgsInvitesIdRoute '/apps/$accountId/$gatewayId': typeof LayoutPublicAppsAccountIdGatewayIdRoute '/apps/$accountId/': typeof LayoutPublicAppsAccountIdIndexRoute } export interface FileRoutesByTo { '/': typeof LayoutPublicIndexRoute + '/login': typeof LayoutAnonLoginRoute '/dashboard': typeof LayoutAuthenticatedDashboardRoute '/$accountId': typeof LayoutPublicAccountIdRoute '/about': typeof LayoutPublicAboutRoute - '/login': typeof LayoutPublicLoginRoute '/skill': typeof LayoutPublicSkillRoute '/admin/system': typeof LayoutAdminAdminSystemRoute - '/accept-invitation/$id': typeof LayoutAuthenticatedAcceptInvitationIdRoute - '/organizations/$slug': typeof LayoutAuthenticatedOrganizationsSlugRoute - '/organizations/new': typeof LayoutAuthenticatedOrganizationsNewRoute + '/orgs/$slug': typeof LayoutAuthenticatedOrgsSlugRoute + '/orgs/new': typeof LayoutAuthenticatedOrgsNewRoute '/settings/auth-methods': typeof LayoutAuthenticatedSettingsAuthMethodsRoute '/settings/profile': typeof LayoutAuthenticatedSettingsProfileRoute '/settings/security': typeof LayoutAuthenticatedSettingsSecurityRoute @@ -258,10 +262,11 @@ export interface FileRoutesByTo { '/things/$thingId': typeof LayoutPublicThingsThingIdRoute '/things/live': typeof LayoutPublicThingsLiveRoute '/admin': typeof LayoutAdminAdminIndexRoute - '/organizations': typeof LayoutAuthenticatedOrganizationsIndexRoute + '/orgs': typeof LayoutAuthenticatedOrgsIndexRoute '/settings': typeof LayoutAuthenticatedSettingsIndexRoute '/apps': typeof LayoutPublicAppsIndexRoute '/things': typeof LayoutPublicThingsIndexRoute + '/orgs/invites/$id': typeof LayoutAuthenticatedOrgsInvitesIdRoute '/apps/$accountId/$gatewayId': typeof LayoutPublicAppsAccountIdGatewayIdRoute '/apps/$accountId': typeof LayoutPublicAppsAccountIdIndexRoute } @@ -269,20 +274,20 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/_layout': typeof LayoutRouteWithChildren '/_layout/_admin': typeof LayoutAdminRouteWithChildren + '/_layout/_anon': typeof LayoutAnonRouteWithChildren '/_layout/_authenticated': typeof LayoutAuthenticatedRouteWithChildren '/_layout/_public': typeof LayoutPublicRouteWithChildren '/_layout/_admin/admin': typeof LayoutAdminAdminRouteWithChildren + '/_layout/_anon/login': typeof LayoutAnonLoginRoute '/_layout/_authenticated/dashboard': typeof LayoutAuthenticatedDashboardRoute '/_layout/_authenticated/settings': typeof LayoutAuthenticatedSettingsRouteWithChildren '/_layout/_public/$accountId': typeof LayoutPublicAccountIdRoute '/_layout/_public/about': typeof LayoutPublicAboutRoute - '/_layout/_public/login': typeof LayoutPublicLoginRoute '/_layout/_public/skill': typeof LayoutPublicSkillRoute '/_layout/_public/': typeof LayoutPublicIndexRoute '/_layout/_admin/admin/system': typeof LayoutAdminAdminSystemRoute - '/_layout/_authenticated/accept-invitation/$id': typeof LayoutAuthenticatedAcceptInvitationIdRoute - '/_layout/_authenticated/organizations/$slug': typeof LayoutAuthenticatedOrganizationsSlugRoute - '/_layout/_authenticated/organizations/new': typeof LayoutAuthenticatedOrganizationsNewRoute + '/_layout/_authenticated/orgs/$slug': typeof LayoutAuthenticatedOrgsSlugRoute + '/_layout/_authenticated/orgs/new': typeof LayoutAuthenticatedOrgsNewRoute '/_layout/_authenticated/settings/auth-methods': typeof LayoutAuthenticatedSettingsAuthMethodsRoute '/_layout/_authenticated/settings/profile': typeof LayoutAuthenticatedSettingsProfileRoute '/_layout/_authenticated/settings/security': typeof LayoutAuthenticatedSettingsSecurityRoute @@ -292,10 +297,11 @@ export interface FileRoutesById { '/_layout/_public/things/$thingId': typeof LayoutPublicThingsThingIdRoute '/_layout/_public/things/live': typeof LayoutPublicThingsLiveRoute '/_layout/_admin/admin/': typeof LayoutAdminAdminIndexRoute - '/_layout/_authenticated/organizations/': typeof LayoutAuthenticatedOrganizationsIndexRoute + '/_layout/_authenticated/orgs/': typeof LayoutAuthenticatedOrgsIndexRoute '/_layout/_authenticated/settings/': typeof LayoutAuthenticatedSettingsIndexRoute '/_layout/_public/apps/': typeof LayoutPublicAppsIndexRoute '/_layout/_public/things/': typeof LayoutPublicThingsIndexRoute + '/_layout/_authenticated/orgs/invites/$id': typeof LayoutAuthenticatedOrgsInvitesIdRoute '/_layout/_public/apps/$accountId/$gatewayId': typeof LayoutPublicAppsAccountIdGatewayIdRoute '/_layout/_public/apps/$accountId/': typeof LayoutPublicAppsAccountIdIndexRoute } @@ -304,16 +310,15 @@ export interface FileRouteTypes { fullPaths: | '/' | '/admin' + | '/login' | '/dashboard' | '/settings' | '/$accountId' | '/about' - | '/login' | '/skill' | '/admin/system' - | '/accept-invitation/$id' - | '/organizations/$slug' - | '/organizations/new' + | '/orgs/$slug' + | '/orgs/new' | '/settings/auth-methods' | '/settings/profile' | '/settings/security' @@ -323,24 +328,24 @@ export interface FileRouteTypes { | '/things/$thingId' | '/things/live' | '/admin/' - | '/organizations/' + | '/orgs/' | '/settings/' | '/apps/' | '/things/' + | '/orgs/invites/$id' | '/apps/$accountId/$gatewayId' | '/apps/$accountId/' fileRoutesByTo: FileRoutesByTo to: | '/' + | '/login' | '/dashboard' | '/$accountId' | '/about' - | '/login' | '/skill' | '/admin/system' - | '/accept-invitation/$id' - | '/organizations/$slug' - | '/organizations/new' + | '/orgs/$slug' + | '/orgs/new' | '/settings/auth-methods' | '/settings/profile' | '/settings/security' @@ -350,30 +355,31 @@ export interface FileRouteTypes { | '/things/$thingId' | '/things/live' | '/admin' - | '/organizations' + | '/orgs' | '/settings' | '/apps' | '/things' + | '/orgs/invites/$id' | '/apps/$accountId/$gatewayId' | '/apps/$accountId' id: | '__root__' | '/_layout' | '/_layout/_admin' + | '/_layout/_anon' | '/_layout/_authenticated' | '/_layout/_public' | '/_layout/_admin/admin' + | '/_layout/_anon/login' | '/_layout/_authenticated/dashboard' | '/_layout/_authenticated/settings' | '/_layout/_public/$accountId' | '/_layout/_public/about' - | '/_layout/_public/login' | '/_layout/_public/skill' | '/_layout/_public/' | '/_layout/_admin/admin/system' - | '/_layout/_authenticated/accept-invitation/$id' - | '/_layout/_authenticated/organizations/$slug' - | '/_layout/_authenticated/organizations/new' + | '/_layout/_authenticated/orgs/$slug' + | '/_layout/_authenticated/orgs/new' | '/_layout/_authenticated/settings/auth-methods' | '/_layout/_authenticated/settings/profile' | '/_layout/_authenticated/settings/security' @@ -383,10 +389,11 @@ export interface FileRouteTypes { | '/_layout/_public/things/$thingId' | '/_layout/_public/things/live' | '/_layout/_admin/admin/' - | '/_layout/_authenticated/organizations/' + | '/_layout/_authenticated/orgs/' | '/_layout/_authenticated/settings/' | '/_layout/_public/apps/' | '/_layout/_public/things/' + | '/_layout/_authenticated/orgs/invites/$id' | '/_layout/_public/apps/$accountId/$gatewayId' | '/_layout/_public/apps/$accountId/' fileRoutesById: FileRoutesById @@ -418,6 +425,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedRouteImport parentRoute: typeof LayoutRoute } + '/_layout/_anon': { + id: '/_layout/_anon' + path: '' + fullPath: '/' + preLoaderRoute: typeof LayoutAnonRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/_admin': { id: '/_layout/_admin' path: '' @@ -439,13 +453,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutPublicSkillRouteImport parentRoute: typeof LayoutPublicRoute } - '/_layout/_public/login': { - id: '/_layout/_public/login' - path: '/login' - fullPath: '/login' - preLoaderRoute: typeof LayoutPublicLoginRouteImport - parentRoute: typeof LayoutPublicRoute - } '/_layout/_public/about': { id: '/_layout/_public/about' path: '/about' @@ -474,6 +481,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedDashboardRouteImport parentRoute: typeof LayoutAuthenticatedRoute } + '/_layout/_anon/login': { + id: '/_layout/_anon/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LayoutAnonLoginRouteImport + parentRoute: typeof LayoutAnonRoute + } '/_layout/_admin/admin': { id: '/_layout/_admin/admin' path: '/admin' @@ -502,11 +516,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedSettingsIndexRouteImport parentRoute: typeof LayoutAuthenticatedSettingsRoute } - '/_layout/_authenticated/organizations/': { - id: '/_layout/_authenticated/organizations/' - path: '/organizations' - fullPath: '/organizations/' - preLoaderRoute: typeof LayoutAuthenticatedOrganizationsIndexRouteImport + '/_layout/_authenticated/orgs/': { + id: '/_layout/_authenticated/orgs/' + path: '/orgs' + fullPath: '/orgs/' + preLoaderRoute: typeof LayoutAuthenticatedOrgsIndexRouteImport parentRoute: typeof LayoutAuthenticatedRoute } '/_layout/_admin/admin/': { @@ -572,25 +586,18 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedSettingsAuthMethodsRouteImport parentRoute: typeof LayoutAuthenticatedSettingsRoute } - '/_layout/_authenticated/organizations/new': { - id: '/_layout/_authenticated/organizations/new' - path: '/organizations/new' - fullPath: '/organizations/new' - preLoaderRoute: typeof LayoutAuthenticatedOrganizationsNewRouteImport + '/_layout/_authenticated/orgs/new': { + id: '/_layout/_authenticated/orgs/new' + path: '/orgs/new' + fullPath: '/orgs/new' + preLoaderRoute: typeof LayoutAuthenticatedOrgsNewRouteImport parentRoute: typeof LayoutAuthenticatedRoute } - '/_layout/_authenticated/organizations/$slug': { - id: '/_layout/_authenticated/organizations/$slug' - path: '/organizations/$slug' - fullPath: '/organizations/$slug' - preLoaderRoute: typeof LayoutAuthenticatedOrganizationsSlugRouteImport - parentRoute: typeof LayoutAuthenticatedRoute - } - '/_layout/_authenticated/accept-invitation/$id': { - id: '/_layout/_authenticated/accept-invitation/$id' - path: '/accept-invitation/$id' - fullPath: '/accept-invitation/$id' - preLoaderRoute: typeof LayoutAuthenticatedAcceptInvitationIdRouteImport + '/_layout/_authenticated/orgs/$slug': { + id: '/_layout/_authenticated/orgs/$slug' + path: '/orgs/$slug' + fullPath: '/orgs/$slug' + preLoaderRoute: typeof LayoutAuthenticatedOrgsSlugRouteImport parentRoute: typeof LayoutAuthenticatedRoute } '/_layout/_admin/admin/system': { @@ -614,6 +621,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutPublicAppsAccountIdGatewayIdRouteImport parentRoute: typeof LayoutPublicRoute } + '/_layout/_authenticated/orgs/invites/$id': { + id: '/_layout/_authenticated/orgs/invites/$id' + path: '/orgs/invites/$id' + fullPath: '/orgs/invites/$id' + preLoaderRoute: typeof LayoutAuthenticatedOrgsInvitesIdRouteImport + parentRoute: typeof LayoutAuthenticatedRoute + } } } @@ -642,6 +656,18 @@ const LayoutAdminRouteWithChildren = LayoutAdminRoute._addFileChildren( LayoutAdminRouteChildren, ) +interface LayoutAnonRouteChildren { + LayoutAnonLoginRoute: typeof LayoutAnonLoginRoute +} + +const LayoutAnonRouteChildren: LayoutAnonRouteChildren = { + LayoutAnonLoginRoute: LayoutAnonLoginRoute, +} + +const LayoutAnonRouteWithChildren = LayoutAnonRoute._addFileChildren( + LayoutAnonRouteChildren, +) + interface LayoutAuthenticatedSettingsRouteChildren { LayoutAuthenticatedSettingsAuthMethodsRoute: typeof LayoutAuthenticatedSettingsAuthMethodsRoute LayoutAuthenticatedSettingsProfileRoute: typeof LayoutAuthenticatedSettingsProfileRoute @@ -669,31 +695,27 @@ const LayoutAuthenticatedSettingsRouteWithChildren = interface LayoutAuthenticatedRouteChildren { LayoutAuthenticatedDashboardRoute: typeof LayoutAuthenticatedDashboardRoute LayoutAuthenticatedSettingsRoute: typeof LayoutAuthenticatedSettingsRouteWithChildren - LayoutAuthenticatedAcceptInvitationIdRoute: typeof LayoutAuthenticatedAcceptInvitationIdRoute - LayoutAuthenticatedOrganizationsSlugRoute: typeof LayoutAuthenticatedOrganizationsSlugRoute - LayoutAuthenticatedOrganizationsNewRoute: typeof LayoutAuthenticatedOrganizationsNewRoute + LayoutAuthenticatedOrgsSlugRoute: typeof LayoutAuthenticatedOrgsSlugRoute + LayoutAuthenticatedOrgsNewRoute: typeof LayoutAuthenticatedOrgsNewRoute LayoutAuthenticatedTenantTenantIdRoute: typeof LayoutAuthenticatedTenantTenantIdRoute LayoutAuthenticatedTenantNewRoute: typeof LayoutAuthenticatedTenantNewRoute LayoutAuthenticatedThingsNewRoute: typeof LayoutAuthenticatedThingsNewRoute - LayoutAuthenticatedOrganizationsIndexRoute: typeof LayoutAuthenticatedOrganizationsIndexRoute + LayoutAuthenticatedOrgsIndexRoute: typeof LayoutAuthenticatedOrgsIndexRoute + LayoutAuthenticatedOrgsInvitesIdRoute: typeof LayoutAuthenticatedOrgsInvitesIdRoute } const LayoutAuthenticatedRouteChildren: LayoutAuthenticatedRouteChildren = { LayoutAuthenticatedDashboardRoute: LayoutAuthenticatedDashboardRoute, LayoutAuthenticatedSettingsRoute: LayoutAuthenticatedSettingsRouteWithChildren, - LayoutAuthenticatedAcceptInvitationIdRoute: - LayoutAuthenticatedAcceptInvitationIdRoute, - LayoutAuthenticatedOrganizationsSlugRoute: - LayoutAuthenticatedOrganizationsSlugRoute, - LayoutAuthenticatedOrganizationsNewRoute: - LayoutAuthenticatedOrganizationsNewRoute, + LayoutAuthenticatedOrgsSlugRoute: LayoutAuthenticatedOrgsSlugRoute, + LayoutAuthenticatedOrgsNewRoute: LayoutAuthenticatedOrgsNewRoute, LayoutAuthenticatedTenantTenantIdRoute: LayoutAuthenticatedTenantTenantIdRoute, LayoutAuthenticatedTenantNewRoute: LayoutAuthenticatedTenantNewRoute, LayoutAuthenticatedThingsNewRoute: LayoutAuthenticatedThingsNewRoute, - LayoutAuthenticatedOrganizationsIndexRoute: - LayoutAuthenticatedOrganizationsIndexRoute, + LayoutAuthenticatedOrgsIndexRoute: LayoutAuthenticatedOrgsIndexRoute, + LayoutAuthenticatedOrgsInvitesIdRoute: LayoutAuthenticatedOrgsInvitesIdRoute, } const LayoutAuthenticatedRouteWithChildren = @@ -702,7 +724,6 @@ const LayoutAuthenticatedRouteWithChildren = interface LayoutPublicRouteChildren { LayoutPublicAccountIdRoute: typeof LayoutPublicAccountIdRoute LayoutPublicAboutRoute: typeof LayoutPublicAboutRoute - LayoutPublicLoginRoute: typeof LayoutPublicLoginRoute LayoutPublicSkillRoute: typeof LayoutPublicSkillRoute LayoutPublicIndexRoute: typeof LayoutPublicIndexRoute LayoutPublicThingsThingIdRoute: typeof LayoutPublicThingsThingIdRoute @@ -716,7 +737,6 @@ interface LayoutPublicRouteChildren { const LayoutPublicRouteChildren: LayoutPublicRouteChildren = { LayoutPublicAccountIdRoute: LayoutPublicAccountIdRoute, LayoutPublicAboutRoute: LayoutPublicAboutRoute, - LayoutPublicLoginRoute: LayoutPublicLoginRoute, LayoutPublicSkillRoute: LayoutPublicSkillRoute, LayoutPublicIndexRoute: LayoutPublicIndexRoute, LayoutPublicThingsThingIdRoute: LayoutPublicThingsThingIdRoute, @@ -734,12 +754,14 @@ const LayoutPublicRouteWithChildren = LayoutPublicRoute._addFileChildren( interface LayoutRouteChildren { LayoutAdminRoute: typeof LayoutAdminRouteWithChildren + LayoutAnonRoute: typeof LayoutAnonRouteWithChildren LayoutAuthenticatedRoute: typeof LayoutAuthenticatedRouteWithChildren LayoutPublicRoute: typeof LayoutPublicRouteWithChildren } const LayoutRouteChildren: LayoutRouteChildren = { LayoutAdminRoute: LayoutAdminRouteWithChildren, + LayoutAnonRoute: LayoutAnonRouteWithChildren, LayoutAuthenticatedRoute: LayoutAuthenticatedRouteWithChildren, LayoutPublicRoute: LayoutPublicRouteWithChildren, } diff --git a/ui/src/routes/_layout/_admin/admin.tsx b/ui/src/routes/_layout/_admin/admin.tsx index e57fd806..9fee1727 100644 --- a/ui/src/routes/_layout/_admin/admin.tsx +++ b/ui/src/routes/_layout/_admin/admin.tsx @@ -55,7 +55,7 @@ function AdminPage() { home </Link> <Link - to="/organizations" + to="/orgs" className="h-10 px-4 inline-flex items-center gap-1.5 text-sm font-medium border-2 border-outset border-border-strong bg-card text-foreground shadow-sm hover:shadow-md active:border-inset active:shadow-none transition-all duration-200 ease-out rounded-[12px]" > organizations @@ -93,7 +93,7 @@ function AdminPage() { label="Organization" value={ <Link - to="/organizations/$slug" + to="/orgs/$slug" params={{ slug: tenant.subdomain }} className="text-foreground hover:underline font-mono" > diff --git a/ui/src/routes/_layout/_admin/admin/index.tsx b/ui/src/routes/_layout/_admin/admin/index.tsx index fd5a7378..137d4e33 100644 --- a/ui/src/routes/_layout/_admin/admin/index.tsx +++ b/ui/src/routes/_layout/_admin/admin/index.tsx @@ -53,7 +53,7 @@ function AdminDashboard() { Manage organizations, members, roles, and invitations. </p> <Button asChild variant="outline" size="sm"> - <Link to="/organizations"> + <Link to="/orgs"> <Users className="h-3.5 w-3.5" /> open organizations </Link> @@ -105,7 +105,7 @@ function AdminDashboard() { there. </p> <Link - to="/organizations/$slug" + to="/orgs/$slug" params={{ slug: tenant.subdomain }} className="h-9 px-3 inline-flex items-center gap-1.5 text-xs font-medium border-2 border-outset border-border-strong bg-card text-foreground shadow-sm hover:shadow-md active:border-inset active:shadow-none transition-all duration-200 ease-out rounded-[10px]" > diff --git a/ui/src/routes/_layout/_anon.tsx b/ui/src/routes/_layout/_anon.tsx new file mode 100644 index 00000000..da38e147 --- /dev/null +++ b/ui/src/routes/_layout/_anon.tsx @@ -0,0 +1,57 @@ +import { createFileRoute, Link, Outlet, redirect, useRouterState } from "@tanstack/react-router"; +import { getAppName, sessionQueryOptions } from "@/app"; +import { ThemeToggle } from "@/components/theme-toggle"; + +export const Route = createFileRoute("/_layout/_anon")({ + beforeLoad: async ({ context }) => { + const { queryClient, authClient } = context; + const initialSession = context.session; + const session = initialSession ?? queryClient.getQueryData( + sessionQueryOptions(authClient, initialSession).queryKey, + ); + if (session?.user) { + throw redirect({ to: "/dashboard", search: {} }); + } + }, + component: AnonLayout, +}); + +function AnonLayout() { + const { runtimeConfig } = Route.useRouteContext(); + const appName = getAppName(runtimeConfig); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + return ( + <div className="flex-1 flex flex-col min-h-0"> + <header className="shrink-0 bg-card/50 border-b border-border transition-all duration-200 overflow-hidden h-12"> + <div className="flex items-center justify-between px-4 sm:px-6 h-12"> + <Link + to="/" + aria-label={`${appName} home`} + className="flex items-center justify-center w-10 h-10 transition-opacity duration-200 hover:opacity-70" + > + <svg + viewBox="0 0 24 24" + fill="currentColor" + className="w-5 h-5 text-foreground" + aria-label={`${appName} logo`} + > + <title>{appName} + + + + +
+ +
+
+ + +
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/routes/_layout/_public/login.tsx b/ui/src/routes/_layout/_anon/login.tsx similarity index 98% rename from ui/src/routes/_layout/_public/login.tsx rename to ui/src/routes/_layout/_anon/login.tsx index abc68a82..fb37f0ee 100644 --- a/ui/src/routes/_layout/_public/login.tsx +++ b/ui/src/routes/_layout/_anon/login.tsx @@ -11,7 +11,7 @@ type SearchParams = { redirect?: string; }; -export const Route = createFileRoute("/_layout/_public/login")({ +export const Route = createFileRoute("/_layout/_anon/login")({ ssr: false, validateSearch: (search: Record): SearchParams => ({ redirect: typeof search.redirect === "string" ? search.redirect : undefined, @@ -200,7 +200,7 @@ function LoginPage() {
diff --git a/ui/src/routes/_layout/_authenticated.tsx b/ui/src/routes/_layout/_authenticated.tsx index 72eae14a..75ca082d 100644 --- a/ui/src/routes/_layout/_authenticated.tsx +++ b/ui/src/routes/_layout/_authenticated.tsx @@ -1,5 +1,5 @@ import { createFileRoute, Link, Outlet, redirect, useRouterState } from "@tanstack/react-router"; -import { ClipboardList, Compass, Globe, Home, Menu, MessageSquare, Shield } from "lucide-react"; +import { ClipboardList, Compass, Globe, Home, Menu, Shield } from "lucide-react"; import { useState } from "react"; import type { SessionData } from "@/app"; import { getAccount, getActiveRuntime, getAppName, sessionQueryOptions } from "@/app"; @@ -96,7 +96,6 @@ function AuthenticatedLayout() { const sidebarItems: SidebarItem[] = [ { icon: Home, label: "dashboard", to: "/dashboard", roleRequired: "anon" }, { icon: Shield, label: "admin", to: "/admin", roleRequired: "admin" }, - { icon: MessageSquare, label: "nostr", to: "/nostr", roleRequired: "admin" }, ]; const visibleItems = filterSidebarByRole(sidebarItems, getUserRole(true, isAdmin)); diff --git a/ui/src/routes/_layout/_authenticated/organizations/$slug.tsx b/ui/src/routes/_layout/_authenticated/orgs/$slug.tsx similarity index 99% rename from ui/src/routes/_layout/_authenticated/organizations/$slug.tsx rename to ui/src/routes/_layout/_authenticated/orgs/$slug.tsx index e95cc943..ae78e315 100644 --- a/ui/src/routes/_layout/_authenticated/organizations/$slug.tsx +++ b/ui/src/routes/_layout/_authenticated/orgs/$slug.tsx @@ -59,7 +59,7 @@ const orgMembersQueryKey = (orgId: string) => ["org-members", orgId] as const; const orgInvitationsQueryKey = (orgId: string) => ["org-invitations", orgId] as const; const orgApiKeysQueryKey = (orgId: string) => ["org-api-keys", orgId] as const; -export const Route = createFileRoute("/_layout/_authenticated/organizations/$slug")({ +export const Route = createFileRoute("/_layout/_authenticated/orgs/$slug")({ head: () => ({ title: "Organization | auth.everything.dev", meta: [{ name: "description", content: "Manage organization details and members." }], @@ -328,7 +328,7 @@ function OrganizationDetail() { onSuccess: async () => { toast.success("You have left the organization"); await queryClient.invalidateQueries({ queryKey: ["organizations"] }); - await router.navigate({ to: "/organizations" }); + await router.navigate({ to: "/orgs" }); }, onError: (error: Error) => toast.error(error.message || "Failed to leave organization"), }); @@ -341,7 +341,7 @@ function OrganizationDetail() { onSuccess: async () => { toast.success("Organization deleted"); await queryClient.invalidateQueries({ queryKey: ["organizations"] }); - await router.navigate({ to: "/organizations" }); + await router.navigate({ to: "/orgs" }); }, onError: (error: Error) => toast.error(error.message || "Failed to delete organization"), }); @@ -369,7 +369,7 @@ function OrganizationDetail() { description="This organization does not exist or you do not have access." action={ } /> @@ -383,7 +383,7 @@ function OrganizationDetail() {
- + Organizations / @@ -657,7 +657,7 @@ function OrganizationDetail() {
- ); + ) } function Chip({ children, accent }: { children: React.ReactNode; accent?: boolean }) { diff --git a/ui/src/routes/_layout/_authenticated/organizations/index.tsx b/ui/src/routes/_layout/_authenticated/orgs/index.tsx similarity index 98% rename from ui/src/routes/_layout/_authenticated/organizations/index.tsx rename to ui/src/routes/_layout/_authenticated/orgs/index.tsx index 96e4c576..ae09675a 100644 --- a/ui/src/routes/_layout/_authenticated/organizations/index.tsx +++ b/ui/src/routes/_layout/_authenticated/orgs/index.tsx @@ -18,7 +18,7 @@ type UserInvitationsResponse = Awaited< >; type UserInvitationItem = NonNullable[number]; -export const Route = createFileRoute("/_layout/_authenticated/organizations/")({ +export const Route = createFileRoute("/_layout/_authenticated/orgs/")({ head: () => ({ title: "Organizations | auth.everything.dev", meta: [{ name: "description", content: "Manage your organizations and teams." }], @@ -114,7 +114,7 @@ function OrganizationsList() { await queryClient.refetchQueries({ queryKey: ["organizations"] }); if (invitation.organizationSlug) { await router.navigate({ - to: "/organizations/$slug", + to: "/orgs/$slug", params: { slug: invitation.organizationSlug }, }); } @@ -164,7 +164,7 @@ function OrganizationsList() { @@ -253,7 +253,7 @@ function OrganizationsList() {

No organizations yet.

create your first org @@ -303,7 +303,7 @@ function OrganizationsList() {
diff --git a/ui/src/routes/_layout/_authenticated/accept-invitation.$id.tsx b/ui/src/routes/_layout/_authenticated/orgs/invites.$id.tsx similarity index 95% rename from ui/src/routes/_layout/_authenticated/accept-invitation.$id.tsx rename to ui/src/routes/_layout/_authenticated/orgs/invites.$id.tsx index e7941f9a..b1c997e1 100644 --- a/ui/src/routes/_layout/_authenticated/accept-invitation.$id.tsx +++ b/ui/src/routes/_layout/_authenticated/orgs/invites.$id.tsx @@ -5,7 +5,7 @@ import { toast } from "sonner"; import { getAppName, useAuthClient } from "@/app"; import { Badge, Button, Card, CardContent } from "@/components"; -export const Route = createFileRoute("/_layout/_authenticated/accept-invitation/$id")({ +export const Route = createFileRoute("/_layout/_authenticated/orgs/invites/$id")({ head: () => ({ meta: [{ title: `Accept Invitation | ${getAppName()}` }], }), @@ -63,7 +63,7 @@ function AcceptInvitation() { ]); await queryClient.refetchQueries({ queryKey: ["organizations"] }); await router.navigate({ - to: "/organizations/$slug", + to: "/orgs/$slug", params: { slug: invitation?.organizationSlug ?? "" }, }); }, @@ -78,7 +78,7 @@ function AcceptInvitation() { onSuccess: async () => { toast.success("Invitation declined"); await queryClient.invalidateQueries({ queryKey: ["user-invitations"] }); - await router.navigate({ to: "/organizations" }); + await router.navigate({ to: "/orgs" }); }, onError: (error: Error) => toast.error(error.message || "Failed to decline invitation"), }); @@ -100,7 +100,7 @@ function AcceptInvitation() { This invitation does not exist, has expired, or is not addressed to your account.

@@ -166,7 +166,7 @@ function AcceptInvitation() {
diff --git a/ui/src/routes/_layout/_authenticated/organizations/new.tsx b/ui/src/routes/_layout/_authenticated/orgs/new.tsx similarity index 96% rename from ui/src/routes/_layout/_authenticated/organizations/new.tsx rename to ui/src/routes/_layout/_authenticated/orgs/new.tsx index 664b81e7..b08c6b31 100644 --- a/ui/src/routes/_layout/_authenticated/organizations/new.tsx +++ b/ui/src/routes/_layout/_authenticated/orgs/new.tsx @@ -7,7 +7,7 @@ import { useAuthClient } from "@/app"; import { Button, Card, CardContent, Input } from "@/components"; import { PageContainer } from "@/components/layout/page-container"; -export const Route = createFileRoute("/_layout/_authenticated/organizations/new")({ +export const Route = createFileRoute("/_layout/_authenticated/orgs/new")({ head: () => ({ title: "New Organization | auth.everything.dev", meta: [{ name: "description", content: "Create a new organization." }], @@ -37,7 +37,7 @@ function NewOrganization() { await queryClient.refetchQueries({ queryKey: ["organizations"] }); if (data?.slug) { await router.navigate({ - to: "/organizations/$slug", + to: "/orgs/$slug", params: { slug: data.slug }, }); } @@ -76,7 +76,7 @@ function NewOrganization() {
@@ -122,7 +122,7 @@ function NewOrganization() {
- ); + ) } function Field({ diff --git a/ui/src/routes/_layout/_authenticated/tenant/$tenantId.tsx b/ui/src/routes/_layout/_authenticated/tenant/$tenantId.tsx index 4a49f5c9..e9d6e78b 100644 --- a/ui/src/routes/_layout/_authenticated/tenant/$tenantId.tsx +++ b/ui/src/routes/_layout/_authenticated/tenant/$tenantId.tsx @@ -403,7 +403,7 @@ function TenantDetail() { there.