feat: complete SSR CRM and refine auth UI
Quality / quality (ubuntu-latest) (push) Failing after 11m17s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-20 16:17:59 +05:30
parent f57bd05a03
commit cd0dffa87d
49 changed files with 1640 additions and 139 deletions
+5
View File
@@ -0,0 +1,5 @@
dist/
.wrnexus/
*.sqlite
*.sqlite-shm
*.sqlite-wal
+13
View File
@@ -0,0 +1,13 @@
# Northstar CRM example
A complete WrNexus example covering public pages, SQLite migrations and seed data, SQL-backed signup/login/session authentication, database-backed roles and permissions, protected pages, and permission-checked CRM APIs.
```bash
bun run db:migrate
bun run db:seed
bun run dev
```
Open `/signup`, create an account, and the signup hook assigns the `sales-rep` role. The user is automatically signed in and redirected to `/dashboard`. Auth tables come from the `@wrnexus/auth` plugin migrations; CRM and authorization tables come from `app/db/migrations/001_crm.sql`.
The seed records use the placeholder owner `demo-owner` to demonstrate repeatable data. To attach them to a registered user, update their `owner_id` to that user's ID.
+35
View File
@@ -0,0 +1,35 @@
import { can } from "@wrnexus/authz";
import { getDb } from "@wrnexus/db";
import type { Context } from "@wrnexus/core";
import { ensureCrmWorkspace } from "../lib/workspace.ts";
const userId = (ctx: Context) => String((ctx.user as { id?: unknown } | undefined)?.id ?? "");
export async function GET(ctx: Context): Promise<Response> {
if (!(await can(ctx, "contact:read")))
return Response.json({ error: "Forbidden" }, { status: 403 });
await ensureCrmWorkspace(userId(ctx));
const rows = await getDb().all(
"SELECT id,name,email,company,phone,status,created_at FROM crm_contacts WHERE owner_id = ? ORDER BY name",
[userId(ctx)],
);
return Response.json({ contacts: rows });
}
export async function POST(ctx: Context): Promise<Response> {
if (!(await can(ctx, "contact:write")))
return Response.json({ error: "Forbidden" }, { status: 403 });
const body = (await ctx.req.json()) as Record<string, unknown>;
const name = String(body.name ?? "").trim();
const email = String(body.email ?? "")
.trim()
.toLowerCase();
if (!name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return Response.json({ error: "A name and valid email are required" }, { status: 400 });
}
const result = await getDb().exec(
"INSERT INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
[userId(ctx), name, email, String(body.company ?? ""), String(body.phone ?? ""), "lead"],
);
return Response.json({ id: Number(result.lastInsertId), name, email }, { status: 201 });
}
+33
View File
@@ -0,0 +1,33 @@
import { can } from "@wrnexus/authz";
import { getDb } from "@wrnexus/db";
import type { Context } from "@wrnexus/core";
import { ensureCrmWorkspace } from "../lib/workspace.ts";
export async function GET(ctx: Context): Promise<Response> {
if (!(await can(ctx, "crm:dashboard")))
return Response.json({ error: "Forbidden" }, { status: 403 });
const owner = String((ctx.user as { id?: unknown } | undefined)?.id ?? "");
await ensureCrmWorkspace(owner);
const metrics = await getDb().one<{
pipeline_value_cents: number;
open_opportunities: number;
contacts: number;
}>(
`SELECT
COALESCE((SELECT SUM(value_cents) FROM crm_deals WHERE owner_id = ? AND stage NOT IN ('won','lost')), 0) pipeline_value_cents,
(SELECT COUNT(*) FROM crm_deals WHERE owner_id = ? AND stage NOT IN ('won','lost')) open_opportunities,
(SELECT COUNT(*) FROM crm_contacts WHERE owner_id = ?) contacts`,
[owner, owner, owner],
);
return Response.json({
metrics: {
pipelineValue: new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(Number(metrics?.pipeline_value_cents ?? 0) / 100),
openOpportunities: Number(metrics?.open_opportunities ?? 0),
contacts: Number(metrics?.contacts ?? 0),
},
});
}
+15
View File
@@ -0,0 +1,15 @@
import { can } from "@wrnexus/authz";
import { getDb } from "@wrnexus/db";
import type { Context } from "@wrnexus/core";
import { ensureCrmWorkspace } from "../lib/workspace.ts";
export async function GET(ctx: Context): Promise<Response> {
if (!(await can(ctx, "deal:read"))) return Response.json({ error: "Forbidden" }, { status: 403 });
const owner = String((ctx.user as { id?: unknown } | undefined)?.id ?? "");
await ensureCrmWorkspace(owner);
const deals = await getDb().all(
"SELECT d.id,d.title,d.value_cents,d.stage,d.close_date,c.name contact_name FROM crm_deals d LEFT JOIN crm_contacts c ON c.id=d.contact_id WHERE d.owner_id=? ORDER BY d.updated_at DESC",
[owner],
);
return Response.json({ deals });
}
+19
View File
@@ -0,0 +1,19 @@
import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({
permissions: {
"crm:dashboard": { title: "View dashboard" },
"contact:read": { title: "View contacts" },
"contact:write": { title: "Create and edit contacts" },
"contact:delete": { title: "Delete contacts", risk: "high" },
"deal:read": { title: "View deals" },
"deal:write": { title: "Create and edit deals" },
"admin:access": { title: "Manage CRM access", risk: "high" },
},
roles: {
viewer: ["crm:dashboard", "contact:read", "deal:read"],
"sales-rep": ["role:viewer", "contact:write", "deal:write"],
manager: ["role:sales-rep", "contact:delete"],
admin: ["role:manager", "admin:access"],
},
});
@@ -0,0 +1,65 @@
-- +up
CREATE TABLE IF NOT EXISTS crm_contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id TEXT NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
company TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'lead' CHECK (status IN ('lead', 'customer', 'inactive')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS crm_contacts_owner_idx ON crm_contacts(owner_id);
CREATE UNIQUE INDEX IF NOT EXISTS crm_contacts_owner_email_uq ON crm_contacts(owner_id, email);
CREATE TABLE IF NOT EXISTS crm_deals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id TEXT NOT NULL,
contact_id INTEGER REFERENCES crm_contacts(id) ON DELETE SET NULL,
title TEXT NOT NULL,
value_cents INTEGER NOT NULL DEFAULT 0 CHECK (value_cents >= 0),
stage TEXT NOT NULL DEFAULT 'qualified' CHECK (stage IN ('qualified', 'proposal', 'won', 'lost')),
close_date TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS crm_deals_owner_idx ON crm_deals(owner_id);
CREATE INDEX IF NOT EXISTS crm_deals_contact_idx ON crm_deals(contact_id);
CREATE TABLE IF NOT EXISTS crm_activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id TEXT NOT NULL,
entity_type TEXT NOT NULL CHECK (entity_type IN ('contact', 'deal', 'account')),
entity_id TEXT NOT NULL,
action TEXT NOT NULL,
details_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS crm_activities_entity_idx ON crm_activities(entity_type, entity_id);
-- Database-backed authorization assignments.
CREATE TABLE IF NOT EXISTS _wrn_authz_assignment (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_id VARCHAR(255) NOT NULL,
scope VARCHAR(255) NOT NULL DEFAULT '',
role VARCHAR(255) NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)
);
CREATE TABLE IF NOT EXISTS _wrn_authz_grant (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_id VARCHAR(255) NOT NULL,
scope VARCHAR(255) NOT NULL DEFAULT '',
permission VARCHAR(255) NOT NULL,
effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)
);
-- +down
DROP TABLE IF EXISTS _wrn_authz_grant;
DROP TABLE IF EXISTS _wrn_authz_assignment;
DROP TABLE IF EXISTS crm_activities;
DROP TABLE IF EXISTS crm_deals;
DROP TABLE IF EXISTS crm_contacts;
+23
View File
@@ -0,0 +1,23 @@
import type { Db } from "@wrnexus/db";
export default async function seed(db: Db): Promise<void> {
const owner = "demo-owner";
await db.exec("DELETE FROM crm_activities");
await db.exec("DELETE FROM crm_deals");
await db.exec("DELETE FROM crm_contacts");
await db.exec(
"INSERT INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
[owner, "Ada Lovelace", "ada@example.test", "Analytical Engines", "+1 555 0101", "customer"],
);
await db.exec(
"INSERT INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
[owner, "Grace Hopper", "grace@example.test", "Compiler Labs", "+1 555 0102", "lead"],
);
const contact = await db.one<{ id: number }>("SELECT id FROM crm_contacts WHERE email = ?", [
"ada@example.test",
]);
await db.exec(
"INSERT INTO crm_deals (owner_id,contact_id,title,value_cents,stage,close_date) VALUES (?,?,?,?,?,?)",
[owner, contact?.id ?? null, "Enterprise rollout", 12500000, "proposal", "2026-12-15"],
);
}
+32
View File
@@ -0,0 +1,32 @@
import { createAuthEngine, SqlAuthStore, type AuthStore } from "@wrnexus/auth";
import { getDb } from "@wrnexus/db";
import { dbPermissionStore } from "@wrnexus/authz/db";
import { ensureCrmWorkspace } from "./workspace.ts";
// Config is imported before the runtime opens its configured database. This
// forwarding store resolves getDb() only when an auth operation actually runs.
const store = new Proxy({} as AuthStore, {
get(_target, property) {
const value = Reflect.get(new SqlAuthStore(getDb()), property);
return typeof value === "function" ? value.bind(new SqlAuthStore(getDb())) : value;
},
});
export const auth = createAuthEngine({
store,
secret: process.env.AUTH_SECRET ?? "northstar-crm-development-secret-change-me",
issuer: "Northstar CRM",
onSignedIn(ctx, returnTo) {
const destination =
returnTo?.startsWith("/") && !returnTo.startsWith("//") ? returnTo : "/dashboard";
return Response.redirect(new URL(destination, ctx.url), 303);
},
onSignedOut(ctx) {
return Response.redirect(new URL("/", ctx.url), 303);
},
async onSuccessfulSignUp(_ctx, user) {
await dbPermissionStore(getDb()).assignRole(user.id, "sales-rep");
await ensureCrmWorkspace(user.id);
return { autoSignIn: true, redirectTo: "/dashboard" };
},
});
+29
View File
@@ -0,0 +1,29 @@
import { getDb } from "@wrnexus/db";
/** Give every real CRM user a useful workspace on first access. */
export async function ensureCrmWorkspace(ownerId: string): Promise<void> {
if (!ownerId) return;
const db = getDb();
await db.exec(
"INSERT OR IGNORE INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
[ownerId, "Ada Lovelace", "ada@example.test", "Analytical Engines", "+1 555 0101", "customer"],
);
await db.exec(
"INSERT OR IGNORE INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
[ownerId, "Grace Hopper", "grace@example.test", "Compiler Labs", "+1 555 0102", "lead"],
);
const existingDeal = await db.one<{ count: number }>(
"SELECT COUNT(*) count FROM crm_deals WHERE owner_id = ?",
[ownerId],
);
if (Number(existingDeal?.count ?? 0) === 0) {
const contact = await db.one<{ id: number }>(
"SELECT id FROM crm_contacts WHERE owner_id = ? AND email = ?",
[ownerId, "ada@example.test"],
);
await db.exec(
"INSERT INTO crm_deals (owner_id,contact_id,title,value_cents,stage,close_date) VALUES (?,?,?,?,?,?)",
[ownerId, contact?.id ?? null, "Enterprise rollout", 12500000, "proposal", "2026-12-15"],
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { authzMiddleware, getAuthzCatalog } from "@wrnexus/authz";
import { getDb } from "@wrnexus/db";
import { dbPermissionStore } from "@wrnexus/authz/db";
import type { Middleware } from "@wrnexus/core";
// Production imports middleware before it opens configured databases. Resolve
// the SQL-backed store on the first request, after runtime initialization.
const middleware: Middleware = (ctx, next) =>
authzMiddleware({ catalog: getAuthzCatalog(), store: dbPermissionStore(getDb()) })(ctx, next);
export default middleware;
@@ -0,0 +1,18 @@
import { requireAuth } from "@wrnexus/auth";
import { can } from "@wrnexus/authz";
import type { Middleware } from "@wrnexus/core";
const guard = requireAuth({ loginPath: "/login" });
const protectedPrefixes = ["/dashboard", "/contacts", "/deals", "/admin"];
const middleware: Middleware = (ctx, next) => {
if (!protectedPrefixes.some((prefix) => ctx.url.pathname.startsWith(prefix))) return next();
return guard(ctx, async () => {
if (ctx.url.pathname.startsWith("/admin") && !(await can(ctx, "admin:access"))) {
return Response.redirect(new URL("/forbidden", ctx.url), 303);
}
return next();
});
};
export default middleware;
+5
View File
@@ -0,0 +1,5 @@
page Admin {
state user = ctx.user
seo { title = "Administration" }
view { <main class="crm-shell"><aside class="crm-sidebar"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><nav class="crm-menu"><a href="/dashboard">Overview</a><a href="/contacts">Contacts</a><a href="/deals">Deals</a><a href="/admin" aria-current="page">Administration</a></nav></aside><section class="crm-content"><header class="crm-topbar"><div><h1>Access administration</h1><p>Manage roles and protect high-risk operations.</p></div></header><section class="crm-panel"><span class="crm-eyebrow">Role-based access</span><h2>Signed in as {user.displayName || user.username}</h2><p class="crm-hero-copy">This route requires the <strong>admin:access</strong> permission. Assign manager and administrator roles through the database-backed authorization store.</p></section></section></main> }
}
+6
View File
@@ -0,0 +1,6 @@
page Contacts {
apis { listContacts GET /api/contacts { response { return data.contacts } } }
load server contacts { return await api.listContacts() }
seo { title = "Contacts" }
view { <main class="crm-shell"><aside class="crm-sidebar"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><nav class="crm-menu"><a href="/dashboard">Overview</a><a href="/contacts" aria-current="page">Contacts</a><a href="/deals">Deals</a><a href="/admin">Administration</a></nav></aside><section class="crm-content"><header class="crm-topbar"><div><h1>Contacts</h1><p>Every relationship, scoped securely to its owner.</p></div><a class="crm-button" href="mailto:sales@example.test">New contact</a></header><section class="crm-panel"><span class="crm-eyebrow">Customer directory</span>{#if contacts.length}<ul class="crm-list">{#each contacts as contact}<li><strong>{contact.name}</strong><span>{contact.company || contact.email}</span><span class="crm-pill">{contact.status}</span></li>{/each}</ul>{/if}{#if !contacts.length}<div class="crm-empty"><h2>No contacts yet</h2><p>Your first customer relationship will appear here.</p></div>{/if}</section></section></main> }
}
+8
View File
@@ -0,0 +1,8 @@
page Dashboard {
state user = ctx.user
state dashboardMetrics = ctx.metrics
apis { dashboard GET /api/dashboard { response { return data.metrics } } }
load server metrics { return await api.dashboard() }
seo { title = "Dashboard" }
view { <main class="crm-shell"><aside class="crm-sidebar"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><nav class="crm-menu"><a href="/dashboard" aria-current="page">Overview</a><a href="/contacts">Contacts</a><a href="/deals">Deals</a><a href="/admin">Administration</a></nav><form class="crm-sidebar-footer" method="post" action="/api/auth/logout" data-schema="auth-empty"><button class="crm-button crm-button--ghost" type="submit">Log out</button></form></aside><section class="crm-content"><header class="crm-topbar"><div><h1>Good to see you, {user.displayName || user.username}</h1><p>Live figures from your private SQLite workspace.</p></div><a class="crm-button" href="/contacts">View contacts</a></header><div class="crm-stat-grid"><article class="crm-stat"><span>Pipeline value</span><strong>{dashboardMetrics.pipelineValue}</strong></article><article class="crm-stat"><span>Open opportunities</span><strong>{dashboardMetrics.openOpportunities}</strong></article><article class="crm-stat"><span>Total contacts</span><strong>{dashboardMetrics.contacts}</strong></article></div><section class="crm-panel"><span class="crm-eyebrow">Server rendered</span><h2>One secure data path</h2><p class="crm-hero-copy">This page was rendered from authenticated API results on the server. No browser prefetch or client data request is needed to show these metrics.</p></section></section></main> }
}
+6
View File
@@ -0,0 +1,6 @@
page Deals {
apis { listDeals GET /api/deals { response { return data.deals } } }
load server deals { return await api.listDeals() }
seo { title = "Deals" }
view { <main class="crm-shell"><aside class="crm-sidebar"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><nav class="crm-menu"><a href="/dashboard">Overview</a><a href="/contacts">Contacts</a><a href="/deals" aria-current="page">Deals</a><a href="/admin">Administration</a></nav></aside><section class="crm-content"><header class="crm-topbar"><div><h1>Sales pipeline</h1><p>Focus on the opportunities most likely to move.</p></div><a class="crm-button" href="/contacts">New deal</a></header><section class="crm-panel"><span class="crm-eyebrow">Open opportunities</span>{#if deals.length}<ul class="crm-list">{#each deals as deal}<li><strong>{deal.title}</strong><span>{deal.contact_name || "Unassigned contact"}</span><span class="crm-pill">{deal.stage}</span></li>{/each}</ul>{/if}{#if !deals.length}<div class="crm-empty"><h2>No open deals</h2><p>Create a contact and turn the relationship into an opportunity.</p></div>{/if}</section></section></main> }
}
+6
View File
@@ -0,0 +1,6 @@
page Forbidden {
seo { title = "Access denied" }
view {
<main class="crm-status-page"><section class="crm-status-card"><span class="crm-status-icon" aria-hidden="true">!</span><span class="crm-eyebrow">Permission required</span><h1>You do not have access to administration.</h1><p>Your account is working correctly, but an administrator role is required for this page.</p><div class="crm-actions"><a class="crm-button" href="/dashboard">Back to dashboard</a><a class="crm-button crm-button--ghost" href="/contacts">View contacts</a></div></section></main>
}
}
+31
View File
@@ -0,0 +1,31 @@
page Home {
seo { title = "CRM that keeps sales moving" }
view {
<main
class="crm-public"
>
<nav
class="crm-nav"
>
<a
class="crm-brand"
href="/"
>
<span
class="crm-brand-mark"
>
N</span>Northstar CRM</a><div class="crm-nav-links"><a href="/pricing">Pricing</a><a href="/login">Log in</a><a class="crm-button" href="/signup">Start free</a></div>
</nav>
<section
class="crm-hero"
>
<div>
<span
class="crm-eyebrow"
>
Customer intelligence, simplified</span><h1>Know every customer. Close every opportunity.</h1><p class="crm-hero-copy">Bring contacts, conversations, and pipeline into one calm workspace built for teams that value clarity.</p><div class="crm-actions"><a class="crm-button" href="/signup">Create your workspace</a><a class="crm-button crm-button--ghost" href="/login">View the CRM</a></div></div><div class="crm-preview"><div class="crm-preview-bar"><span></span><span></span><span></span></div><div class="crm-preview-grid"><article class="crm-preview-card"><small>Pipeline value</small><strong>$125k</strong><small>+18% this month</small></article><article class="crm-preview-card"><small>Active contacts</small><strong>248</strong><small>32 need follow-up</small></article><article class="crm-preview-card"><small>Win rate</small><strong>42%</strong><small>Across qualified deals</small></article><article class="crm-preview-card"><small>Next action</small><strong>8 today</strong><small>Stay ahead of every promise</small></article></div>
</div>
</section>
</main>
}
}
+10
View File
@@ -0,0 +1,10 @@
import AuthSplitLayout from "@wrnexus/ui/components/AuthSplitLayout.wrn"
page Login {
seo { title = "Log in" }
view {
<AuthSplitLayout brand="Northstar CRM" eyebrow="Customer intelligence" title="Turn every conversation into momentum." description="Sign in to a focused workspace for contacts, pipeline, and activity." features='[{"label":"One customer timeline","description":"Every relationship and opportunity in context."},{"label":"Secure by default","description":"SQL sessions and role-based authorization."},{"label":"Built for focus","description":"A calm workspace without sales-tool clutter."}]'>
<div data-slot="form"><SignIn action="/api/auth/login" returnTo="/dashboard" signUpHref="/signup" forgotHref="/login" showPasskey="false" class="border-0 shadow-none" /></div>
</AuthSplitLayout>
}
}
+4
View File
@@ -0,0 +1,4 @@
page Pricing {
seo { title = "Pricing" }
view { <main class="crm-public"><nav class="crm-nav"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><div class="crm-nav-links"><a href="/login">Log in</a><a class="crm-button" href="/signup">Start free</a></div></nav><section class="crm-hero"><div><span class="crm-eyebrow">Simple pricing</span><h1>Start free. Scale when your team does.</h1><p class="crm-hero-copy">Everything needed to evaluate a secure, full-stack CRM locally. Move to Team when you are ready to collaborate.</p></div><div class="crm-preview"><article class="crm-preview-card"><small>Starter</small><strong>$0</strong><p>SQLite workspace, CRM flows, authentication and permissions.</p><a class="crm-button" href="/signup">Start building</a></article><article class="crm-preview-card"><small>Team</small><strong>$29</strong><p>Per user/month with shared pipeline and administration.</p></article></div></section></main> }
}
+10
View File
@@ -0,0 +1,10 @@
import AuthSplitLayout from "@wrnexus/ui/components/AuthSplitLayout.wrn"
page Signup {
seo { title = "Create account" }
view {
<AuthSplitLayout brand="Northstar CRM" eyebrow="Start in minutes" title="Build relationships, not spreadsheets." description="Create your secure sales workspace and begin with a complete customer view." features='[{"label":"Free local workspace","description":"Explore the complete CRM flow with SQLite."},{"label":"Flexible permissions","description":"Viewer, sales, manager, and admin roles."},{"label":"Your data stays yours","description":"Portable SQL data and explicit migrations."}]'>
<div data-slot="form"><SignUp redirect="/dashboard" signInHref="/login" showPhone="false" showUsername="false" requireConsent="false" class="border-0 shadow-none" /></div>
</AuthSplitLayout>
}
}
+116
View File
@@ -0,0 +1,116 @@
// AUTO-GENERATED by `wrnexus dev` - do not edit.
// Typed routes support required, optional, and catch-all parameters.
export interface Routes {
"/": Record<string, never>;
"/admin": Record<string, never>;
"/contacts": Record<string, never>;
"/dashboard": Record<string, never>;
"/deals": Record<string, never>;
"/forbidden": Record<string, never>;
"/login": Record<string, never>;
"/pricing": Record<string, never>;
"/signup": Record<string, never>;
}
export interface RouteNames {
"index": "/";
"admin": "/admin";
"contacts": "/contacts";
"dashboard": "/dashboard";
"deals": "/deals";
"forbidden": "/forbidden";
"login": "/login";
"pricing": "/pricing";
"signup": "/signup";
}
export interface RouteQueries {
[path: string]: Record<string, string | number | boolean | null | undefined>;
}
export type RoutePath = keyof Routes;
export type RouteName = keyof RouteNames;
export type RouteQuery<P extends RoutePath> = P extends keyof RouteQueries
? RouteQueries[P]
: Record<string, string | number | boolean | null | undefined>;
type RouteValue = string | readonly string[] | undefined;
function encodeRouteValue(value: RouteValue, catchAll: boolean): string {
if (value === undefined) return "";
const values = Array.isArray(value) ? value : catchAll ? String(value).split("/") : [String(value)];
return values.map((part) => encodeURIComponent(part)).join("/");
}
function buildHref(path: string, params: Record<string, RouteValue> = {}): string {
const output: string[] = [];
for (const segment of path.split("/").filter(Boolean)) {
let name: string | undefined;
let optional = false;
let catchAll = false;
if (segment.startsWith("[[") && segment.endsWith("]]")) {
optional = true;
name = segment.slice(2, -2);
} else if (segment.startsWith("[") && segment.endsWith("]")) {
name = segment.slice(1, -1);
if (name.endsWith("?")) {
optional = true;
name = name.slice(0, -1);
}
}
if (!name) {
output.push(segment);
continue;
}
if (name.startsWith("...")) {
catchAll = true;
name = name.slice(3);
}
const value = params[name];
if (value === undefined && optional) continue;
if (value === undefined) throw new Error(`WRN-ROUTE-MISSING-PARAM: Missing route parameter '${name}'.`);
output.push(encodeRouteValue(value, catchAll));
}
return "/" + output.filter(Boolean).join("/");
}
export function href<P extends RoutePath>(
path: P,
...args: keyof Routes[P] extends never
? []
: Record<string, never> extends Routes[P]
? [params?: Routes[P]]
: [params: Routes[P]]
): string {
const params = (args[0] ?? {}) as Record<string, RouteValue>;
return buildHref(String(path), params);
}
export function route<N extends RouteName>(
name: N,
...args: keyof Routes[RouteNames[N]] extends never
? [params?: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
: Record<string, never> extends Routes[RouteNames[N]]
? [params?: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
: [params: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
): string {
const paths: Record<RouteName, RoutePath> = {
"index": "/",
"admin": "/admin",
"contacts": "/contacts",
"dashboard": "/dashboard",
"deals": "/deals",
"forbidden": "/forbidden",
"login": "/login",
"pricing": "/pricing",
"signup": "/signup"
} as Record<RouteName, RoutePath>;
const output = buildHref(paths[name], (args[0] ?? {}) as Record<string, RouteValue>);
const query = args[1];
if (!query) return output;
const search = new URLSearchParams();
for (const [key, value] of Object.entries(query))
if (value !== undefined && value !== null) search.set(key, String(value));
const text = search.toString();
return text ? `${output}?${text}` : output;
}
+375
View File
@@ -0,0 +1,375 @@
@import "tailwindcss";
@plugin "@iconify/tailwind4";
@source "../**/*.wrn";
@source "../../../packages/auth/components/*.wrn";
@source "../../../packages/ui/components/*.wrn";
:root {
color-scheme: light;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
min-width: 320px;
background: var(--wrn-color-bg);
}
body {
color: var(--wrn-color-text);
background: var(--wrn-color-bg);
font-family: Inter, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
.crm-public {
min-height: 100dvh;
overflow: hidden;
background:
radial-gradient(
circle at 80% 10%,
color-mix(in srgb, var(--wrn-color-primary) 15%, transparent),
transparent 28%
),
var(--wrn-color-bg);
}
.crm-nav {
width: min(1180px, calc(100% - 2rem));
margin: auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 0;
}
.crm-brand {
display: inline-flex;
align-items: center;
gap: 0.7rem;
font-weight: 800;
letter-spacing: -0.025em;
}
.crm-brand-mark {
display: grid;
width: 2.25rem;
height: 2.25rem;
place-items: center;
border-radius: 0.75rem;
color: white;
background: linear-gradient(135deg, var(--wrn-color-primary), #7c3aed);
box-shadow: 0 10px 24px color-mix(in srgb, var(--wrn-color-primary) 28%, transparent);
}
.crm-nav-links {
display: flex;
align-items: center;
gap: 1.5rem;
color: var(--wrn-color-muted);
font-size: 0.9rem;
font-weight: 600;
}
.crm-button {
display: inline-flex;
min-height: 2.75rem;
align-items: center;
justify-content: center;
padding: 0 1.1rem;
border-radius: 0.75rem;
color: white;
background: var(--wrn-color-primary);
font-weight: 700;
box-shadow: 0 10px 24px color-mix(in srgb, var(--wrn-color-primary) 22%, transparent);
}
.crm-button--ghost {
color: var(--wrn-color-text);
background: var(--wrn-color-surface);
border: 1px solid var(--wrn-color-border);
box-shadow: none;
}
.crm-hero {
width: min(1180px, calc(100% - 2rem));
margin: auto;
display: grid;
grid-template-columns: 1.05fr 0.95fr;
align-items: center;
gap: 4rem;
padding: clamp(4rem, 9vw, 8rem) 0;
}
.crm-eyebrow {
color: var(--wrn-color-primary);
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.crm-hero h1 {
max-width: 12ch;
margin: 1rem 0;
font-size: clamp(3rem, 7vw, 5.5rem);
line-height: 0.98;
letter-spacing: -0.065em;
}
.crm-hero-copy {
max-width: 38rem;
color: var(--wrn-color-muted);
font-size: 1.1rem;
line-height: 1.75;
}
.crm-actions {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin-top: 2rem;
}
.crm-preview {
padding: 1rem;
border: 1px solid var(--wrn-color-border);
border-radius: 1.5rem;
background: color-mix(in srgb, var(--wrn-color-surface) 88%, transparent);
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.15);
transform: rotate(1.5deg);
}
.crm-preview-bar {
display: flex;
gap: 0.4rem;
padding: 0.4rem 0.25rem 1rem;
}
.crm-preview-bar span {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--wrn-color-border);
}
.crm-preview-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.8rem;
}
.crm-preview-card {
min-height: 8rem;
padding: 1rem;
border-radius: 1rem;
background: var(--wrn-color-surface-2);
}
.crm-preview-card strong {
display: block;
margin-top: 0.7rem;
font-size: 1.6rem;
}
.crm-preview-card small {
color: var(--wrn-color-muted);
}
.crm-shell {
min-height: 100dvh;
display: grid;
grid-template-columns: 16rem 1fr;
background: var(--wrn-color-bg);
}
.crm-sidebar {
position: sticky;
top: 0;
height: 100dvh;
display: flex;
flex-direction: column;
padding: 1.35rem;
border-right: 1px solid var(--wrn-color-border);
background: var(--wrn-color-surface);
}
.crm-menu {
display: grid;
gap: 0.35rem;
margin-top: 2rem;
}
.crm-menu a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.85rem;
border-radius: 0.75rem;
color: var(--wrn-color-muted);
font-size: 0.9rem;
font-weight: 650;
}
.crm-menu a:hover,
.crm-menu a[aria-current="page"] {
color: var(--wrn-color-primary);
background: color-mix(in srgb, var(--wrn-color-primary) 10%, transparent);
}
.crm-sidebar-footer {
margin-top: auto;
}
.crm-content {
min-width: 0;
padding: clamp(1.25rem, 4vw, 3rem);
}
.crm-topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
}
.crm-topbar h1 {
margin: 0;
font-size: clamp(1.75rem, 4vw, 2.4rem);
letter-spacing: -0.04em;
}
.crm-topbar p {
margin: 0.4rem 0 0;
color: var(--wrn-color-muted);
}
.crm-panel {
padding: 1.25rem;
border: 1px solid var(--wrn-color-border);
border-radius: 1rem;
background: var(--wrn-color-surface);
box-shadow: var(--wrn-shadow-1);
}
.crm-stat-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.crm-stat {
padding: 1.25rem;
border: 1px solid var(--wrn-color-border);
border-radius: 1rem;
background: var(--wrn-color-surface);
}
.crm-stat span {
color: var(--wrn-color-muted);
font-size: 0.8rem;
font-weight: 650;
}
.crm-stat strong {
display: block;
margin-top: 0.6rem;
font-size: 2rem;
letter-spacing: -0.04em;
}
.crm-list {
display: grid;
gap: 0.65rem;
padding: 0;
margin: 1rem 0 0;
list-style: none;
}
.crm-list li {
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(8rem, 0.7fr) auto;
align-items: center;
gap: 1rem;
padding: 1rem;
border: 1px solid var(--wrn-color-border);
border-radius: 0.8rem;
background: var(--wrn-color-surface-2);
}
.crm-pill {
justify-self: end;
padding: 0.3rem 0.65rem;
border-radius: 999px;
color: var(--wrn-color-primary);
background: color-mix(in srgb, var(--wrn-color-primary) 10%, transparent);
font-size: 0.72rem;
font-weight: 750;
text-transform: capitalize;
}
.crm-empty {
padding: 3rem 1rem;
text-align: center;
color: var(--wrn-color-muted);
}
.crm-empty h2 {
margin-bottom: 0.35rem;
color: var(--wrn-color-text);
}
.crm-status-page {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1.5rem;
background: radial-gradient(
circle at 50% 0%,
color-mix(in srgb, var(--wrn-color-primary) 12%, transparent),
transparent 45%
);
}
.crm-status-card {
width: min(34rem, 100%);
padding: clamp(1.5rem, 5vw, 3rem);
border: 1px solid var(--wrn-color-border);
border-radius: 1.25rem;
background: var(--wrn-color-surface);
box-shadow: var(--wrn-shadow-2);
}
.crm-status-card h1 {
margin: 0.9rem 0 0.7rem;
font-size: clamp(1.8rem, 5vw, 2.5rem);
line-height: 1.08;
letter-spacing: -0.045em;
}
.crm-status-card > p {
color: var(--wrn-color-muted);
line-height: 1.65;
}
.crm-status-icon {
display: grid;
width: 3rem;
height: 3rem;
margin-bottom: 1.25rem;
place-items: center;
border-radius: 1rem;
color: #b45309;
background: #fef3c7;
font-size: 1.4rem;
font-weight: 800;
}
@media (max-width: 820px) {
.crm-nav-links > a:not(.crm-button) {
display: none;
}
.crm-hero {
grid-template-columns: 1fr;
padding-top: 3rem;
}
.crm-preview {
transform: none;
}
.crm-shell {
grid-template-columns: 1fr;
}
.crm-sidebar {
position: static;
width: 100%;
height: auto;
}
.crm-menu {
grid-template-columns: repeat(4, 1fr);
overflow: auto;
}
.crm-sidebar-footer {
margin-top: 1rem;
}
.crm-stat-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.crm-hero h1 {
font-size: 3rem;
}
.crm-menu {
grid-template-columns: repeat(2, 1fr);
}
.crm-list li {
grid-template-columns: 1fr;
}
.crm-pill {
justify-self: start;
}
}
@@ -0,0 +1,7 @@
// AUTO-GENERATED by `wrnexus generate types` - do not edit.
//
// Type-only assertions for sectioned `api` blocks. Kept as a real .ts file (not
// wrnexus.generated.d.ts) because `skipLibCheck` exempts .d.ts contents from being
// checked; this file is compiled and checked normally by the project's own tsc.
export {};
+47
View File
@@ -0,0 +1,47 @@
// AUTO-GENERATED by `wrnexus generate types` - do not edit.
declare namespace WRNexusGenerated {
type ApiContract<T> = T extends import("@wrnexus/core").DefinedEndpoint<infer I, infer O>
? { input: I; output: O }
: T extends (...args: infer A) => infer R
? { input: A extends [any, infer I, ...any[]] ? I : unknown; output: Awaited<R> }
: { input: unknown; output: unknown };
type MiddlewareContext<T> = T extends (ctx: infer C, ...args: any[]) => any ? C : never;
type QueryContract<T> = T extends (db: any, args: infer A, ...rest: any[]) => infer R
? { args: A; result: Awaited<R> }
: T extends (db: any, ...rest: any[]) => infer R
? { args: Record<string, never>; result: Awaited<R> }
: never;
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
type RouteName = "admin" | "contacts" | "dashboard" | "deals" | "forbidden" | "index" | "login" | "pricing" | "signup";
type ApiRoute = "/api/contacts" | "/api/dashboard" | "/api/deals";
type RealtimeRoute = never;
type EnvironmentKey = never;
type TranslationKey = never;
type QueueName = never;
type CacheKey = never;
type Components = Record<string, never>;
interface ApiContracts {
"/api/dashboard": { GET: ApiContract<typeof import("../api/dashboard.ts")["GET"]> };
"/api/contacts": { GET: ApiContract<typeof import("../api/contacts.ts")["GET"]>; POST: ApiContract<typeof import("../api/contacts.ts")["POST"]> };
"/api/deals": { GET: ApiContract<typeof import("../api/deals.ts")["GET"]> };
}
interface MiddlewareContexts {
"authz": MiddlewareContext<(typeof import("../middleware/authz.ts"))["default"]>;
"protected": MiddlewareContext<(typeof import("../middleware/protected.ts"))["default"]>;
}
type DatabaseQueries = Record<string, never>;
type RealtimeMessages = Record<string, never>;
type QueuePayloads = Record<string, never>;
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
type AssertAssignable<Actual, Expected> = unknown extends Expected
? true
: [Actual] extends [Expected]
? [Exclude<keyof Actual, keyof Expected>] extends [never]
? true
: false
: false;
type __wrn_expect_true<T extends true> = T;
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
}
@@ -0,0 +1 @@
// AUTO-GENERATED plugin type aggregation - do not edit.
+30
View File
@@ -0,0 +1,30 @@
{
"name": "wrnexus-crm-example",
"version": "0.8.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run ../../packages/cli/src/index.ts dev .",
"build": "bun run ../../packages/cli/src/index.ts build .",
"db:migrate": "bun run ../../packages/cli/src/index.ts db migrate .",
"db:seed": "bun run ../../packages/cli/src/index.ts db seed .",
"test": "bun test test",
"typecheck": "tsc --noEmit -p tsconfig.json",
"check": "bun run typecheck && bun run test && bun run build"
},
"dependencies": {
"@wrnexus/auth": "workspace:*",
"@wrnexus/authz": "workspace:*",
"@wrnexus/core": "workspace:*",
"@wrnexus/db": "workspace:*",
"@wrnexus/styles": "workspace:*"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.3.3",
"@types/bun": "^1.3.14",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
}
}
+82
View File
@@ -0,0 +1,82 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createDb, loadMigrations, migrate } from "@wrnexus/db";
import { sqlite } from "@wrnexus/db/sqlite";
import seed from "../app/db/seed.ts";
const root = join(import.meta.dir, "..");
const source = (path: string) => readFileSync(join(root, path), "utf8");
test("the CRM migration applies to SQLite, is idempotent, and the seed is repeatable", async () => {
const db = createDb(sqlite());
const migrations = join(root, "app", "db", "migrations");
expect(loadMigrations(migrations).map(({ name }) => name)).toEqual(["001_crm"]);
expect(await migrate(db, migrations)).toEqual(["001_crm"]);
expect(await migrate(db, migrations)).toEqual([]);
await seed(db);
await seed(db);
expect(await db.one<{ count: number }>("SELECT COUNT(*) count FROM crm_contacts")).toEqual({
count: 2,
});
expect(await db.one<{ count: number }>("SELECT COUNT(*) count FROM crm_deals")).toEqual({
count: 1,
});
expect(
await db.all(
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('_wrn_authz_assignment','_wrn_authz_grant') ORDER BY name",
),
).toHaveLength(2);
await db.close();
});
test("authentication uses SQL, plugin migrations, signup/login components, and protected routes", () => {
expect(source("app/lib/auth.ts")).toContain("SqlAuthStore");
expect(source("wrnexus.config.ts")).toContain("migrations: true");
expect(source("app/pages/login.wrn")).toContain("<SignIn");
expect(source("app/pages/signup.wrn")).toContain("<SignUp");
expect(source("app/middleware/protected.ts")).toContain('requireAuth({ loginPath: "/login" })');
expect(source("app/pages/dashboard.wrn")).toContain("/api/auth/logout");
expect(source("app/pages/login.wrn")).toContain("AuthSplitLayout");
expect(source("app/pages/login.wrn")).toContain('data-slot="form"');
expect(source("app/pages/signup.wrn")).not.toContain("Already registered?");
expect(source("wrnexus.config.ts")).toContain('entry: "app/styles/global.css"');
});
test("authorization is database-backed and guards both APIs and administration", () => {
expect(source("app/middleware/authz.ts")).toContain("dbPermissionStore(getDb())");
expect(source("app/lib/auth.ts")).toContain('assignRole(user.id, "sales-rep")');
expect(source("app/api/contacts.ts")).toContain('can(ctx, "contact:write")');
expect(source("app/api/deals.ts")).toContain('can(ctx, "deal:read")');
expect(source("app/middleware/protected.ts")).toContain('can(ctx, "admin:access")');
});
test("the app includes public, authentication, and protected CRM pages", () => {
for (const page of [
"index",
"pricing",
"login",
"signup",
"dashboard",
"contacts",
"deals",
"admin",
"forbidden",
]) {
expect(source(`app/pages/${page}.wrn`)).toContain("page ");
}
});
test("protected CRM data is loaded on the server without browser API bindings", () => {
for (const page of ["contacts", "deals", "dashboard"]) {
const contents = source(`app/pages/${page}.wrn`);
expect(contents).toContain("load server");
expect(contents).not.toContain(' api="');
}
expect(source("app/api/dashboard.ts")).toContain("pipeline_value_cents");
expect(source("app/lib/workspace.ts")).toContain("INSERT OR IGNORE INTO crm_contacts");
expect(source("app/lib/auth.ts")).toContain("ensureCrmWorkspace(user.id)");
expect(source("app/middleware/protected.ts")).toContain('new URL("/forbidden", ctx.url)');
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": { "noEmit": true },
"include": ["app/**/*.ts", "test/**/*.ts", "wrnexus.config.ts"]
}
+38
View File
@@ -0,0 +1,38 @@
import { join } from "node:path";
import type { AuthConfig } from "@wrnexus/auth";
import type { AppConfig } from "@wrnexus/styles";
import { auth } from "./app/lib/auth.ts";
const config = {
seo: {
title: "Northstar CRM",
titleTemplate: "%s | Northstar CRM",
description: "A complete WrNexus SQLite CRM example.",
canonicalBase: "http://localhost:3000",
},
theme: { palette: "blue", default: "light" },
db: { driver: "sqlite", url: "file:./crm.sqlite" },
styles: {
entry: "app/styles/global.css",
async process({ entryPath, appRoot, mode }) {
const args = ["@tailwindcss/cli", "-i", entryPath!];
if (mode === "production") args.push("--minify");
return await Bun.$.cwd(appRoot)`bunx ${args}`.text();
},
failureMode: "throw",
},
auth: {
engine: auth,
routes: true,
middleware: true,
components: true,
migrations: true,
baseUrl: "http://localhost:3000",
},
profiles: {
test: { db: { driver: "sqlite", url: `file:${join(import.meta.dir, ".tmp-test.sqlite")}` } },
},
security: { cors: { enabled: false } },
} satisfies AppConfig & { auth: AuthConfig };
export default config;