feat: complete SSR CRM and refine auth UI
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
dist/
|
||||
.wrnexus/
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
@@ -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.
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"],
|
||||
);
|
||||
}
|
||||
@@ -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" };
|
||||
},
|
||||
});
|
||||
@@ -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"],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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> }
|
||||
}
|
||||
@@ -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> }
|
||||
}
|
||||
@@ -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> }
|
||||
}
|
||||
@@ -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> }
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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> }
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)');
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": { "noEmit": true },
|
||||
"include": ["app/**/*.ts", "test/**/*.ts", "wrnexus.config.ts"]
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user