Merge dev

This commit is contained in:
NeilClack
2026-07-17 11:06:32 -05:00
147 changed files with 37626 additions and 20067 deletions
@@ -0,0 +1,183 @@
---
name: better-auth-best-practices
description: Configure Better Auth server and client, set up database adapters, manage sessions, add plugins, and handle environment variables. Use when users mention Better Auth, betterauth, auth.ts, or need to set up TypeScript authentication with email/password, OAuth, or plugin configuration.
---
# Better Auth Integration Guide
**Always consult [better-auth.com/docs](https://better-auth.com/docs) for code examples and latest API.**
---
## Setup Workflow
1. Install: `npm install better-auth`
2. Set env vars: `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL`
3. Create `auth.ts` with database + config
4. Create route handler for your framework
5. Run `npx @better-auth/cli@latest migrate`
6. Verify: call `GET /api/auth/ok` — should return `{ status: "ok" }`
---
## Quick Reference
### Environment Variables
- `BETTER_AUTH_SECRET` - Encryption secret (min 32 chars). Generate: `openssl rand -base64 32`
- `BETTER_AUTH_URL` - Base URL (e.g., `https://example.com`)
Only define `baseURL`/`secret` in config if env vars are NOT set.
### File Location
CLI looks for `auth.ts` in: `./`, `./lib`, `./utils`, or under `./src`. Use `--config` for custom path.
### CLI Commands
- `npx @better-auth/cli@latest migrate` - Apply schema (built-in adapter)
- `npx @better-auth/cli@latest generate` - Generate schema for Prisma/Drizzle
- `npx @better-auth/cli mcp --cursor` - Add MCP to AI tools
**Re-run after adding/changing plugins.**
---
## Core Config Options
| Option | Notes |
| ------------------ | ---------------------------------------------- |
| `appName` | Optional display name |
| `baseURL` | Only if `BETTER_AUTH_URL` not set |
| `basePath` | Default `/api/auth`. Set `/` for root. |
| `secret` | Only if `BETTER_AUTH_SECRET` not set |
| `database` | Required for most features. See adapters docs. |
| `secondaryStorage` | Redis/KV for sessions & rate limits |
| `emailAndPassword` | `{ enabled: true }` to activate |
| `socialProviders` | `{ google: { clientId, clientSecret }, ... }` |
| `plugins` | Array of plugins |
| `trustedOrigins` | CSRF whitelist |
---
## Database
**Direct connections:** Pass `pg.Pool`, `mysql2` pool, `better-sqlite3`, or `bun:sqlite` instance.
**ORM adapters:** Import from `better-auth/adapters/drizzle`, `better-auth/adapters/prisma`, `better-auth/adapters/mongodb`.
**Critical:** Better Auth uses adapter model names, NOT underlying table names. If Prisma model is `User` mapping to table `users`, use `modelName: "user"` (Prisma reference), not `"users"`.
---
## Session Management
**Storage priority:**
1. If `secondaryStorage` defined → sessions go there (not DB)
2. Set `session.storeSessionInDatabase: true` to also persist to DB
3. No database + `cookieCache` → fully stateless mode
**Cookie cache strategies:**
- `compact` (default) - Base64url + HMAC. Smallest.
- `jwt` - Standard JWT. Readable but signed.
- `jwe` - Encrypted. Maximum security.
**Key options:** `session.expiresIn` (default 7 days), `session.updateAge` (refresh interval), `session.cookieCache.maxAge`, `session.cookieCache.version` (change to invalidate all sessions).
---
## User & Account Config
**User:** `user.modelName`, `user.fields` (column mapping), `user.additionalFields`, `user.changeEmail.enabled` (disabled by default), `user.deleteUser.enabled` (disabled by default).
**Account:** `account.modelName`, `account.accountLinking.enabled`, `account.storeAccountCookie` (for stateless OAuth).
**Required for registration:** `email` and `name` fields.
---
## Email Flows
- `emailVerification.sendVerificationEmail` - Must be defined for verification to work
- `emailVerification.sendOnSignUp` / `sendOnSignIn` - Auto-send triggers
- `emailAndPassword.sendResetPassword` - Password reset email handler
---
## Security
**In `advanced`:**
- `useSecureCookies` - Force HTTPS cookies
- `disableCSRFCheck` - ⚠️ Security risk
- `disableOriginCheck` - ⚠️ Security risk
- `crossSubDomainCookies.enabled` - Share cookies across subdomains
- `ipAddress.ipAddressHeaders` - Custom IP headers for proxies
- `database.generateId` - Custom ID generation or `"serial"`/`"uuid"`/`false`
**Rate limiting:** `rateLimit.enabled`, `rateLimit.window`, `rateLimit.max`, `rateLimit.storage` ("memory" | "database" | "secondary-storage").
---
## Hooks
**Endpoint hooks:** `hooks.before` / `hooks.after` - Array of `{ matcher, handler }`. Use `createAuthMiddleware`. Access `ctx.path`, `ctx.context.returned` (after), `ctx.context.session`.
**Database hooks:** `databaseHooks.user.create.before/after`, same for `session`, `account`. Useful for adding default values or post-creation actions.
**Hook context (`ctx.context`):** `session`, `secret`, `authCookies`, `password.hash()`/`verify()`, `adapter`, `internalAdapter`, `generateId()`, `tables`, `baseURL`.
---
## Plugins
**Import from dedicated paths for tree-shaking:**
```
import { twoFactor } from "better-auth/plugins/two-factor"
```
NOT `from "better-auth/plugins"`.
**Popular plugins:** `twoFactor`, `organization`, `passkey`, `magicLink`, `emailOtp`, `username`, `phoneNumber`, `admin`, `apiKey`, `bearer`, `jwt`, `multiSession`, `sso`, `oauthProvider`, `oidcProvider`, `openAPI`, `genericOAuth`.
Client plugins go in `createAuthClient({ plugins: [...] })`.
---
## Client
Import from: `better-auth/client` (vanilla), `better-auth/react`, `better-auth/vue`, `better-auth/svelte`, `better-auth/solid`.
Key methods: `signUp.email()`, `signIn.email()`, `signIn.social()`, `signOut()`, `useSession()`, `getSession()`, `revokeSession()`, `revokeSessions()`.
---
## Type Safety
Infer types: `typeof auth.$Infer.Session`, `typeof auth.$Infer.Session.user`.
For separate client/server projects: `createAuthClient<typeof auth>()`.
---
## Common Gotchas
1. **Model vs table name** - Config uses ORM model name, not DB table name
2. **Plugin schema** - Re-run CLI after adding plugins
3. **Secondary storage** - Sessions go there by default, not DB
4. **Cookie cache** - Custom session fields NOT cached, always re-fetched
5. **Stateless mode** - No DB = session in cookie only, logout on cache expiry
6. **Change email flow** - Sends to current email first, then new email
---
## Resources
- [Docs](https://better-auth.com/docs)
- [Options Reference](https://better-auth.com/docs/reference/options)
- [LLMs.txt](https://better-auth.com/llms.txt)
- [GitHub](https://github.com/better-auth/better-auth)
- [Init Options Source](https://github.com/better-auth/better-auth/blob/main/packages/core/src/types/init-options.ts)
@@ -0,0 +1,426 @@
---
name: better-auth-security-best-practices
description: Configure rate limiting, manage auth secrets, set up CSRF protection, define trusted origins, secure sessions and cookies, encrypt OAuth tokens, track IP addresses, and implement audit logging for Better Auth. Use when users need to secure their auth setup, prevent brute force attacks, or harden a Better Auth deployment.
---
## Secret Management
### Configuring the Secret
```ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
secret: process.env.BETTER_AUTH_SECRET // or via `BETTER_AUTH_SECRET` env
});
```
Better Auth looks for secrets in this order:
1. `options.secret` in your config
2. `BETTER_AUTH_SECRET` environment variable
3. `AUTH_SECRET` environment variable
### Secret Requirements
- Rejects default/placeholder secrets in production
- Warns if shorter than 32 characters or entropy below 120 bits
- Generate: `openssl rand -base64 32`
- Never commit secrets to version control
## Rate Limiting
Enabled in production by default. Applies to all endpoints. Plugins can override per-endpoint.
### Default Configuration
```ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
rateLimit: {
enabled: true, // Default: true in production
window: 10, // Time window in seconds (default: 10)
max: 100 // Max requests per window (default: 100)
}
});
```
### Storage Options
Options: `"memory"` (resets on restart, avoid on serverless), `"database"` (persistent), `"secondary-storage"` (Redis, default when available).
```ts
rateLimit: {
storage: "database",
}
```
### Custom Storage
Implement your own rate limit storage:
```ts
rateLimit: {
customStorage: {
get: async (key) => {
// Return { count: number, expiresAt: number } or null
},
set: async (key, data) => {
// Store the rate limit data
},
},
}
```
### Per-Endpoint Rules
Sensitive endpoints default to 3 requests per 10 seconds (`/sign-in`, `/sign-up`, `/change-password`, `/change-email`). Override:
```ts
rateLimit: {
customRules: {
"/api/auth/sign-in/email": {
window: 60, // 1 minute window
max: 5, // 5 attempts
},
"/api/auth/some-safe-endpoint": false, // Disable rate limiting
},
}
```
## CSRF Protection
Multi-layer protection: origin header validation, Fetch Metadata checks, and first-login protection.
### Configuration
```ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
advanced: {
disableCSRFCheck: false // Default: false (keep enabled)
}
});
```
Only disable for testing or with an alternative CSRF mechanism.
## Trusted Origins
### Configuring Trusted Origins
```ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
baseURL: 'https://api.example.com',
trustedOrigins: ['https://app.example.com', 'https://admin.example.com']
});
```
The `baseURL` origin is automatically trusted. Also configurable via env: `BETTER_AUTH_TRUSTED_ORIGINS=https://app.example.com,https://admin.example.com`
### Wildcard Patterns
```ts
trustedOrigins: [
'*.example.com', // Matches any subdomain
'https://*.example.com', // Protocol-specific wildcard
'exp://192.168.*.*:*/*' // Custom schemes (e.g., Expo)
];
```
### Dynamic Trusted Origins
Compute trusted origins based on the request:
```ts
trustedOrigins: async (request) => {
// Validate against database, header, etc.
const tenant = getTenantFromRequest(request);
return [`https://${tenant}.myapp.com`];
};
```
Validates `callbackURL`, `redirectTo`, `errorCallbackURL`, `newUserCallbackURL`, and `origin` against trusted origins. Invalid URLs receive 403.
## Session Security
### Session Expiration
```ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days (default)
updateAge: 60 * 60 * 24 // Refresh session every 24 hours (default)
}
});
```
### Session Caching Strategies
Cache session data in cookies to reduce database queries:
```ts
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
strategy: "compact", // Options: "compact", "jwt", "jwe"
},
}
```
Strategies: `"compact"` (Base64url + HMAC, smallest), `"jwt"` (HS256, standard), `"jwe"` (encrypted, use when session has sensitive data).
## Cookie Security
Defaults: `secure: true` (HTTPS/production), `sameSite: "lax"`, `httpOnly: true`, `path: "/"`, prefix `__Secure-`.
### Custom Cookie Configuration
```ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
advanced: {
useSecureCookies: true, // Force secure cookies
cookiePrefix: 'myapp', // Custom prefix (default: "better-auth")
defaultCookieAttributes: {
sameSite: 'strict', // Stricter CSRF protection
path: '/auth' // Limit cookie scope
}
}
});
```
### Cross-Subdomain Cookies
```ts
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: ".example.com", // Note the leading dot
additionalCookies: ["session_token", "session_data"],
},
}
```
Only enable if you need authentication sharing and trust all subdomains.
## OAuth / Social Provider Security
PKCE is automatic for all OAuth flows. State tokens are 32-char random strings expiring after 10 minutes.
### State Parameter Storage
```ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
account: {
storeStateStrategy: 'cookie' // Options: "cookie" (default), "database"
}
});
```
### Encrypting OAuth Tokens
```ts
account: {
encryptOAuthTokens: true, // Uses AES-256-GCM
}
```
Enable if storing OAuth tokens for API access on behalf of users. Use `skipStateCookieCheck: true` only for mobile apps that cannot maintain cookies.
## IP-Based Security
### IP Address Configuration
```ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
advanced: {
ipAddress: {
ipAddressHeaders: ['x-forwarded-for', 'x-real-ip'], // Headers to check
disableIpTracking: false // Keep enabled for rate limiting
}
}
});
```
Set `ipv6Subnet` (128, 64, 48, 32; default 64) to group IPv6 addresses. Enable `trustedProxyHeaders: true` only if behind a trusted reverse proxy.
## Database Hooks for Security Auditing
```ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
databaseHooks: {
session: {
create: {
after: async ({ data, ctx }) => {
await auditLog('session.created', {
userId: data.userId,
ip: ctx?.request?.headers.get('x-forwarded-for'),
userAgent: ctx?.request?.headers.get('user-agent')
});
}
},
delete: {
before: async ({ data }) => {
await auditLog('session.revoked', { sessionId: data.id });
}
}
},
user: {
update: {
after: async ({ data, oldData }) => {
if (oldData?.email !== data.email) {
await auditLog('user.email_changed', {
userId: data.id,
oldEmail: oldData?.email,
newEmail: data.email
});
}
}
}
},
account: {
create: {
after: async ({ data }) => {
await auditLog('account.linked', {
userId: data.userId,
provider: data.providerId
});
}
}
}
}
});
```
Return `false` from a `before` hook to prevent an operation.
## Background Tasks
```ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
advanced: {
backgroundTasks: {
handler: (promise) => {
// Platform-specific handler
// Vercel: waitUntil(promise)
// Cloudflare: ctx.waitUntil(promise)
waitUntil(promise);
}
}
}
});
```
Ensures operations like sending emails don't affect response timing.
## Account Enumeration Prevention
Built-in: consistent response messages, dummy operations on invalid requests, background email sending. Return generic error messages ("Invalid credentials") rather than specific ones ("User not found").
## Complete Security Configuration Example
```ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
secret: process.env.BETTER_AUTH_SECRET,
baseURL: 'https://api.example.com',
trustedOrigins: ['https://app.example.com', 'https://*.preview.example.com'],
// Rate limiting
rateLimit: {
enabled: true,
storage: 'secondary-storage',
customRules: {
'/api/auth/sign-in/email': { window: 60, max: 5 },
'/api/auth/sign-up/email': { window: 60, max: 3 }
}
},
// Session security
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 24 hours
freshAge: 60 * 60, // 1 hour for sensitive actions
cookieCache: {
enabled: true,
maxAge: 300,
strategy: 'jwe' // Encrypted session data
}
},
// OAuth security
account: {
encryptOAuthTokens: true,
storeStateStrategy: 'cookie'
},
// Advanced settings
advanced: {
useSecureCookies: true,
cookiePrefix: 'myapp',
defaultCookieAttributes: {
sameSite: 'lax'
},
ipAddress: {
ipAddressHeaders: ['x-forwarded-for'],
ipv6Subnet: 64
},
backgroundTasks: {
handler: (promise) => waitUntil(promise)
}
},
// Security auditing
databaseHooks: {
session: {
create: {
after: async ({ data, ctx }) => {
console.log(`New session for user ${data.userId}`);
}
}
},
user: {
update: {
after: async ({ data, oldData }) => {
if (oldData?.email !== data.email) {
console.log(`Email changed for user ${data.id}`);
}
}
}
}
}
});
```
## Security Checklist
Before deploying to production:
- [ ] **Secret**: Use a strong, unique secret (32+ characters, high entropy)
- [ ] **HTTPS**: Ensure `baseURL` uses HTTPS
- [ ] **Trusted Origins**: Configure all valid origins (frontend, mobile apps)
- [ ] **Rate Limiting**: Keep enabled with appropriate limits
- [ ] **CSRF Protection**: Keep enabled (`disableCSRFCheck: false`)
- [ ] **Secure Cookies**: Enabled automatically with HTTPS
- [ ] **OAuth Tokens**: Consider `encryptOAuthTokens: true` if storing tokens
- [ ] **Background Tasks**: Configure for serverless platforms
- [ ] **Audit Logging**: Implement via `databaseHooks` or `hooks`
- [ ] **IP Tracking**: Configure headers if behind a proxy
+327
View File
@@ -0,0 +1,327 @@
---
name: create-auth-skill
description: Scaffold and implement authentication in TypeScript/JavaScript apps using Better Auth. Detect frameworks, configure database adapters, set up route handlers, add OAuth providers, and create auth UI pages. Use when users want to add login, sign-up, or authentication to a new or existing project with Better Auth.
---
# Create Auth Skill
Guide for adding authentication to TypeScript/JavaScript applications using Better Auth.
**For code examples and syntax, see [better-auth.com/docs](https://better-auth.com/docs).**
---
## Phase 1: Planning (REQUIRED before implementation)
Before writing any code, gather requirements by scanning the project and asking the user structured questions. This ensures the implementation matches their needs.
### Step 1: Scan the project
Analyze the codebase to auto-detect:
- **Framework** — Look for `next.config`, `svelte.config`, `nuxt.config`, `astro.config`, `vite.config`, or Express/Hono entry files.
- **Database/ORM** — Look for `prisma/schema.prisma`, `drizzle.config`, `package.json` deps (`pg`, `mysql2`, `better-sqlite3`, `mongoose`, `mongodb`).
- **Existing auth** — Look for existing auth libraries (`next-auth`, `lucia`, `clerk`, `supabase/auth`, `firebase/auth`) in `package.json` or imports.
- **Package manager** — Check for `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`, or `package-lock.json`.
Use what you find to pre-fill defaults and skip questions you can already answer.
### Step 2: Ask planning questions
Use the `AskQuestion` tool to ask the user **all applicable questions in a single call**. Skip any question you already have a confident answer for from the scan. Group them under a title like "Auth Setup Planning".
**Questions to ask:**
1. **Project type** (skip if detected)
- Prompt: "What type of project is this?"
- Options: New project from scratch | Adding auth to existing project | Migrating from another auth library
2. **Framework** (skip if detected)
- Prompt: "Which framework are you using?"
- Options: Next.js (App Router) | Next.js (Pages Router) | SvelteKit | Nuxt | Astro | Express | Hono | SolidStart | Other
3. **Database & ORM** (skip if detected)
- Prompt: "Which database setup will you use?"
- Options: PostgreSQL (Prisma) | PostgreSQL (Drizzle) | PostgreSQL (pg driver) | MySQL (Prisma) | MySQL (Drizzle) | MySQL (mysql2 driver) | SQLite (Prisma) | SQLite (Drizzle) | SQLite (better-sqlite3 driver) | MongoDB (Mongoose) | MongoDB (native driver)
4. **Authentication methods** (always ask, allow multiple)
- Prompt: "Which sign-in methods do you need?"
- Options: Email & password | Social OAuth (Google, GitHub, etc.) | Magic link (passwordless email) | Passkey (WebAuthn) | Phone number
- `allow_multiple: true`
5. **Social providers** (only if they selected Social OAuth above — ask in a follow-up call)
- Prompt: "Which social providers do you need?"
- Options: Google | GitHub | Apple | Microsoft | Discord | Twitter/X
- `allow_multiple: true`
6. **Email verification** (only if Email & password was selected above — ask in a follow-up call)
- Prompt: "Do you want to require email verification?"
- Options: Yes | No
7. **Email provider** (only if email verification is Yes, or if Password reset is selected in features — ask in a follow-up call)
- Prompt: "How do you want to send emails?"
- Options: Resend | Mock it for now (console.log)
8. **Features & plugins** (always ask, allow multiple)
- Prompt: "Which additional features do you need?"
- Options: Two-factor authentication (2FA) | Organizations / teams | Admin dashboard | API bearer tokens | Password reset | None of these
- `allow_multiple: true`
9. **Auth pages** (always ask, allow multiple — pre-select based on earlier answers)
- Prompt: "Which auth pages do you need?"
- Options vary based on previous answers:
- Always available: Sign in | Sign up
- If Email & password selected: Forgot password | Reset password
- If email verification enabled: Email verification
- `allow_multiple: true`
10. **Auth UI style** (always ask)
- Prompt: "What style do you want for the auth pages? Pick one or describe your own."
- Options: Minimal & clean | Centered card with background | Split layout (form + hero image) | Floating / glassmorphism | Other (I'll describe)
### Step 3: Summarize the plan
After collecting answers, present a concise implementation plan as a markdown checklist. Example:
```
## Auth Implementation Plan
- **Framework:** Next.js (App Router)
- **Database:** PostgreSQL via Prisma
- **Auth methods:** Email/password, Google OAuth, GitHub OAuth
- **Plugins:** 2FA, Organizations, Email verification
- **UI:** Custom forms
### Steps
1. Install `better-auth` and `@better-auth/cli`
2. Create `lib/auth.ts` with server config
3. Create `lib/auth-client.ts` with React client
4. Set up route handler at `app/api/auth/[...all]/route.ts`
5. Configure Prisma adapter and generate schema
6. Add Google & GitHub OAuth providers
7. Enable `twoFactor` and `organization` plugins
8. Set up email verification handler
9. Run migrations
10. Create sign-in / sign-up pages
```
Ask the user to confirm the plan before proceeding to Phase 2.
---
## Phase 2: Implementation
Only proceed here after the user confirms the plan from Phase 1.
Follow the decision tree below, guided by the answers collected above.
```
Is this a new/empty project?
├─ YES → New project setup
│ 1. Install better-auth (+ scoped packages per plan)
│ 2. Create auth.ts with all planned config
│ 3. Create auth-client.ts with framework client
│ 4. Set up route handler
│ 5. Set up environment variables
│ 6. Run CLI migrate/generate
│ 7. Add plugins from plan
│ 8. Create auth UI pages
│
├─ MIGRATING → Migration from existing auth
│ 1. Audit current auth for gaps
│ 2. Plan incremental migration
│ 3. Install better-auth alongside existing auth
│ 4. Migrate routes, then session logic, then UI
│ 5. Remove old auth library
│ 6. See migration guides in docs
│
└─ ADDING → Add auth to existing project
1. Analyze project structure
2. Install better-auth
3. Create auth config matching plan
4. Add route handler
5. Run schema migrations
6. Integrate into existing pages
7. Add planned plugins and features
```
At the end of implementation, guide users thoroughly on remaining next steps (e.g., setting up OAuth app credentials, deploying env vars, testing flows).
---
## Installation
**Core:** `npm install better-auth`
**Scoped packages (as needed):**
| Package | Use case |
|---------|----------|
| `@better-auth/passkey` | WebAuthn/Passkey auth |
| `@better-auth/sso` | SAML/OIDC enterprise SSO |
| `@better-auth/stripe` | Stripe payments |
| `@better-auth/scim` | SCIM user provisioning |
| `@better-auth/expo` | React Native/Expo |
---
## Environment Variables
```env
BETTER_AUTH_SECRET=<32+ chars, generate with: openssl rand -base64 32>
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL=<your database connection string>
```
Add OAuth secrets as needed: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, etc.
---
## Server Config (auth.ts)
**Location:** `lib/auth.ts` or `src/lib/auth.ts`
**Minimal config needs:**
- `database` - Connection or adapter
- `emailAndPassword: { enabled: true }` - For email/password auth
**Standard config adds:**
- `socialProviders` - OAuth providers (google, github, etc.)
- `emailVerification.sendVerificationEmail` - Email verification handler
- `emailAndPassword.sendResetPassword` - Password reset handler
**Full config adds:**
- `plugins` - Array of feature plugins
- `session` - Expiry, cookie cache settings
- `account.accountLinking` - Multi-provider linking
- `rateLimit` - Rate limiting config
**Export types:** `export type Session = typeof auth.$Infer.Session`
---
## Client Config (auth-client.ts)
**Import by framework:**
| Framework | Import |
|-----------|--------|
| React/Next.js | `better-auth/react` |
| Vue | `better-auth/vue` |
| Svelte | `better-auth/svelte` |
| Solid | `better-auth/solid` |
| Vanilla JS | `better-auth/client` |
**Client plugins** go in `createAuthClient({ plugins: [...] })`.
**Common exports:** `signIn`, `signUp`, `signOut`, `useSession`, `getSession`
---
## Route Handler Setup
| Framework | File | Handler |
| ------------------ | -------------------------------- | ------------------------------------------------ |
| Next.js App Router | `app/api/auth/[...all]/route.ts` | `toNextJsHandler(auth)` → export `{ GET, POST }` |
| Next.js Pages | `pages/api/auth/[...all].ts` | `toNextJsHandler(auth)` → default export |
| Express | Any file | `app.all("/api/auth/*", toNodeHandler(auth))` |
| SvelteKit | `src/hooks.server.ts` | `svelteKitHandler(auth)` |
| SolidStart | Route file | `solidStartHandler(auth)` |
| Hono | Route file | `auth.handler(c.req.raw)` |
**Next.js Server Components:** Add `nextCookies()` plugin to auth config.
---
## Database Migrations
| Adapter | Command |
| --------------- | -------------------------------------------------------------------------------------------------- |
| Built-in Kysely | `npx @better-auth/cli@latest migrate` (applies directly) |
| Prisma | `npx @better-auth/cli@latest generate --output prisma/schema.prisma` then `npx prisma migrate dev` |
| Drizzle | `npx @better-auth/cli@latest generate --output src/db/auth-schema.ts` then `npx drizzle-kit push` |
**Re-run after adding plugins.**
---
## Database Adapters
| Database | Setup |
| ---------- | -------------------------------------------------------------------------------------- |
| SQLite | Pass `better-sqlite3` or `bun:sqlite` instance directly |
| PostgreSQL | Pass `pg.Pool` instance directly |
| MySQL | Pass `mysql2` pool directly |
| Prisma | `prismaAdapter(prisma, { provider: "postgresql" })` from `better-auth/adapters/prisma` |
| Drizzle | `drizzleAdapter(db, { provider: "pg" })` from `better-auth/adapters/drizzle` |
| MongoDB | `mongodbAdapter(db)` from `better-auth/adapters/mongodb` |
---
## Common Plugins
| Plugin | Server Import | Client Import | Purpose |
| -------------- | ---------------------- | -------------------- | ----------------- |
| `twoFactor` | `better-auth/plugins` | `twoFactorClient` | 2FA with TOTP/OTP |
| `organization` | `better-auth/plugins` | `organizationClient` | Teams/orgs |
| `admin` | `better-auth/plugins` | `adminClient` | User management |
| `bearer` | `better-auth/plugins` | - | API token auth |
| `openAPI` | `better-auth/plugins` | - | API docs |
| `passkey` | `@better-auth/passkey` | `passkeyClient` | WebAuthn |
| `sso` | `@better-auth/sso` | - | Enterprise SSO |
**Plugin pattern:** Server plugin + client plugin + run migrations.
---
## Auth UI Implementation
**Sign in flow:**
1. `signIn.email({ email, password })` or `signIn.social({ provider, callbackURL })`
2. Handle `error` in response
3. Redirect on success
**Session check (client):** `useSession()` hook returns `{ data: session, isPending }`
**Session check (server):** `auth.api.getSession({ headers: await headers() })`
**Protected routes:** Check session, redirect to `/sign-in` if null.
---
## Security Checklist
- [ ] `BETTER_AUTH_SECRET` set (32+ chars)
- [ ] `advanced.useSecureCookies: true` in production
- [ ] `trustedOrigins` configured
- [ ] Rate limits enabled
- [ ] Email verification enabled
- [ ] Password reset implemented
- [ ] 2FA for sensitive apps
- [ ] CSRF protection NOT disabled
- [ ] `account.accountLinking` reviewed
---
## Troubleshooting
| Issue | Fix |
| ------------------------------- | ------------------------------------------------------------- |
| "Secret not set" | Add `BETTER_AUTH_SECRET` env var |
| "Invalid Origin" | Add domain to `trustedOrigins` |
| Cookies not setting | Check `baseURL` matches domain; enable secure cookies in prod |
| OAuth callback errors | Verify redirect URIs in provider dashboard |
| Type errors after adding plugin | Re-run CLI generate/migrate |
---
## Resources
- [Docs](https://better-auth.com/docs)
- [Examples](https://github.com/better-auth/examples)
- [Plugins](https://better-auth.com/docs/concepts/plugins)
- [CLI](https://better-auth.com/docs/concepts/cli)
- [Migration Guides](https://better-auth.com/docs/guides)
@@ -0,0 +1,211 @@
---
name: email-and-password-best-practices
description: Configure email verification, implement password reset flows, set password policies, and customise hashing algorithms for Better Auth email/password authentication. Use when users need to set up login, sign-in, sign-up, credential authentication, or password security with Better Auth.
---
## Quick Start
1. Enable email/password: `emailAndPassword: { enabled: true }`
2. Configure `emailVerification.sendVerificationEmail`
3. Add `sendResetPassword` for password reset flows
4. Run `npx @better-auth/cli@latest migrate`
5. Verify: attempt sign-up and confirm verification email triggers
---
## Email Verification Setup
Configure `emailVerification.sendVerificationEmail` to verify user email addresses.
```ts
import { betterAuth } from 'better-auth';
import { sendEmail } from './email'; // your email sending function
export const auth = betterAuth({
emailVerification: {
sendVerificationEmail: async ({ user, url, token }, request) => {
await sendEmail({
to: user.email,
subject: 'Verify your email address',
text: `Click the link to verify your email: ${url}`
});
}
}
});
```
**Note**: The `url` parameter contains the full verification link. The `token` is available if you need to build a custom verification URL.
### Requiring Email Verification
For stricter security, enable `emailAndPassword.requireEmailVerification` to block sign-in until the user verifies their email. When enabled, unverified users will receive a new verification email on each sign-in attempt.
```ts
export const auth = betterAuth({
emailAndPassword: {
requireEmailVerification: true
}
});
```
**Note**: This requires `sendVerificationEmail` to be configured and only applies to email/password sign-ins.
## Client Side Validation
Implement client-side validation for immediate user feedback and reduced server load.
## Callback URLs
Always use absolute URLs (including the origin) for callback URLs in sign-up and sign-in requests. This prevents Better Auth from needing to infer the origin, which can cause issues when your backend and frontend are on different domains.
```ts
const { data, error } = await authClient.signUp.email({
callbackURL: 'https://example.com/callback' // absolute URL with origin
});
```
## Password Reset Flows
Provide `sendResetPassword` in the email and password config to enable password resets.
```ts
import { betterAuth } from 'better-auth';
import { sendEmail } from './email'; // your email sending function
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
// Custom email sending function to send reset-password email
sendResetPassword: async ({ user, url, token }, request) => {
void sendEmail({
to: user.email,
subject: 'Reset your password',
text: `Click the link to reset your password: ${url}`
});
},
// Optional event hook
onPasswordReset: async ({ user }, request) => {
// your logic here
console.log(`Password for user ${user.email} has been reset.`);
}
}
});
```
### Security Considerations
Built-in protections: background email sending (timing attack prevention), dummy operations on invalid requests, constant response messages regardless of user existence.
On serverless platforms, configure a background task handler:
```ts
export const auth = betterAuth({
advanced: {
backgroundTasks: {
handler: (promise) => {
// Use platform-specific methods like waitUntil
waitUntil(promise);
}
}
}
});
```
#### Token Security
Tokens expire after 1 hour by default. Configure with `resetPasswordTokenExpiresIn` (in seconds):
```ts
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
resetPasswordTokenExpiresIn: 60 * 30 // 30 minutes
}
});
```
Tokens are single-use — deleted immediately after successful reset.
#### Session Revocation
Enable `revokeSessionsOnPasswordReset` to invalidate all existing sessions on password reset:
```ts
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
revokeSessionsOnPasswordReset: true
}
});
```
#### Password Requirements
Password length limits (configurable):
```ts
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
minPasswordLength: 12,
maxPasswordLength: 256
}
});
```
### Sending the Password Reset
Call `requestPasswordReset` to send the reset link. Triggers the `sendResetPassword` function from your config.
```ts
const data = await auth.api.requestPasswordReset({
body: {
email: 'john.doe@example.com', // required
redirectTo: 'https://example.com/reset-password'
}
});
```
Or authClient:
```ts
const { data, error } = await authClient.requestPasswordReset({
email: 'john.doe@example.com', // required
redirectTo: 'https://example.com/reset-password'
});
```
**Note**: While the `email` is required, we also recommend configuring the `redirectTo` for a smoother user experience.
## Password Hashing
Default: `scrypt` (Node.js native, no external dependencies).
### Custom Hashing Algorithm
To use Argon2id or another algorithm, provide custom `hash` and `verify` functions:
```ts
import { betterAuth } from 'better-auth';
import { hash, verify, type Options } from '@node-rs/argon2';
const argon2Options: Options = {
memoryCost: 65536, // 64 MiB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 parallel lanes
outputLen: 32, // 32 byte output
algorithm: 2 // Argon2id variant
};
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
password: {
hash: (password) => hash(password, argon2Options),
verify: ({ password, hash: storedHash }) => verify(storedHash, password, argon2Options)
}
}
});
```
**Note**: If you switch hashing algorithms on an existing system, users with passwords hashed using the old algorithm won't be able to sign in. Plan a migration strategy if needed.
@@ -0,0 +1,478 @@
---
name: organization-best-practices
description: Configure multi-tenant organizations, manage members and invitations, define custom roles and permissions, set up teams, and implement RBAC using Better Auth's organization plugin. Use when users need org setup, team management, member roles, access control, or the Better Auth organization plugin.
---
## Setup
1. Add `organization()` plugin to server config
2. Add `organizationClient()` plugin to client config
3. Run `npx @better-auth/cli migrate`
4. Verify: check that organization, member, invitation tables exist in your database
```ts
import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
organization({
allowUserToCreateOrganization: true,
organizationLimit: 5, // Max orgs per user
membershipLimit: 100 // Max members per org
})
]
});
```
### Client-Side Setup
```ts
import { createAuthClient } from 'better-auth/client';
import { organizationClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [organizationClient()]
});
```
## Creating Organizations
The creator is automatically assigned the `owner` role.
```ts
const createOrg = async () => {
const { data, error } = await authClient.organization.create({
name: 'My Company',
slug: 'my-company',
logo: 'https://example.com/logo.png',
metadata: { plan: 'pro' }
});
};
```
### Controlling Organization Creation
Restrict who can create organizations based on user attributes:
```ts
organization({
allowUserToCreateOrganization: async (user) => {
return user.emailVerified === true;
},
organizationLimit: async (user) => {
// Premium users get more organizations
return user.plan === 'premium' ? 20 : 3;
}
});
```
### Creating Organizations on Behalf of Users
Administrators can create organizations for other users (server-side only):
```ts
await auth.api.createOrganization({
body: {
name: 'Client Organization',
slug: 'client-org',
userId: 'user-id-who-will-be-owner' // `userId` is required
}
});
```
**Note**: The `userId` parameter cannot be used alongside session headers.
## Active Organizations
Stored in the session and scopes subsequent API calls. Set after user selects one.
```ts
const setActive = async (organizationId: string) => {
const { data, error } = await authClient.organization.setActive({
organizationId
});
};
```
Many endpoints use the active organization when `organizationId` is not provided (`listMembers`, `listInvitations`, `inviteMember`, etc.).
Use `getFullOrganization()` to retrieve the active org with all members, invitations, and teams.
## Members
### Adding Members (Server-Side)
```ts
await auth.api.addMember({
body: {
userId: 'user-id',
role: 'member',
organizationId: 'org-id'
}
});
```
For client-side member additions, use the invitation system instead.
### Assigning Multiple Roles
```ts
await auth.api.addMember({
body: {
userId: 'user-id',
role: ['admin', 'moderator'],
organizationId: 'org-id'
}
});
```
### Removing Members
Use `removeMember({ memberIdOrEmail })`. The last owner cannot be removed — assign ownership to another member first.
### Updating Member Roles
Use `updateMemberRole({ memberId, role })`.
### Membership Limits
```ts
organization({
membershipLimit: async (user, organization) => {
if (organization.metadata?.plan === 'enterprise') {
return 1000;
}
return 50;
}
});
```
## Invitations
### Setting Up Invitation Emails
```ts
import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';
import { sendEmail } from './email';
export const auth = betterAuth({
plugins: [
organization({
sendInvitationEmail: async (data) => {
const { email, organization, inviter, invitation } = data;
await sendEmail({
to: email,
subject: `Join ${organization.name}`,
html: `
<p>${inviter.user.name} invited you to join ${organization.name}</p>
<a href="https://yourapp.com/accept-invite?id=${invitation.id}">
Accept Invitation
</a>
`
});
}
})
]
});
```
### Sending Invitations
```ts
await authClient.organization.inviteMember({
email: 'newuser@example.com',
role: 'member'
});
```
### Shareable Invitation URLs
```ts
const { data } = await authClient.organization.getInvitationURL({
email: 'newuser@example.com',
role: 'member',
callbackURL: 'https://yourapp.com/dashboard'
});
// Share data.url via any channel
```
This endpoint does not call `sendInvitationEmail` — handle delivery yourself.
### Invitation Configuration
```ts
organization({
invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days (default: 48 hours)
invitationLimit: 100, // Max pending invitations per org
cancelPendingInvitationsOnReInvite: true // Cancel old invites when re-inviting
});
```
## Roles & Permissions
Default roles: `owner` (full access), `admin` (manage members/invitations/settings), `member` (basic access).
### Checking Permissions
```ts
const { data } = await authClient.organization.hasPermission({
permission: 'member:write'
});
if (data?.hasPermission) {
// User can manage members
}
```
Use `checkRolePermission({ role, permissions })` for client-side UI rendering (static only). For dynamic access control, use the `hasPermission` endpoint.
## Teams
### Enabling Teams
```ts
import { organization } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
organization({
teams: {
enabled: true
}
})
]
});
```
### Creating Teams
```ts
const { data } = await authClient.organization.createTeam({
name: 'Engineering'
});
```
### Managing Team Members
Use `addTeamMember({ teamId, userId })` (member must be in org first) and `removeTeamMember({ teamId, userId })` (stays in org).
Set active team with `setActiveTeam({ teamId })`.
### Team Limits
```ts
organization({
teams: {
maximumTeams: 20, // Max teams per org
maximumMembersPerTeam: 50, // Max members per team
allowRemovingAllTeams: false // Prevent removing last team
}
});
```
## Dynamic Access Control
### Enabling Dynamic Access Control
```ts
import { organization } from 'better-auth/plugins';
import { dynamicAccessControl } from '@better-auth/organization/addons';
export const auth = betterAuth({
plugins: [
organization({
dynamicAccessControl: {
enabled: true
}
})
]
});
```
### Creating Custom Roles
```ts
await authClient.organization.createRole({
role: 'moderator',
permission: {
member: ['read'],
invitation: ['read']
}
});
```
Use `updateRole({ roleId, permission })` and `deleteRole({ roleId })`. Pre-defined roles (owner, admin, member) cannot be deleted. Roles assigned to members cannot be deleted until reassigned.
## Lifecycle Hooks
Execute custom logic at various points in the organization lifecycle:
```ts
organization({
hooks: {
organization: {
beforeCreate: async ({ data, user }) => {
// Validate or modify data before creation
return {
data: {
...data,
metadata: { ...data.metadata, createdBy: user.id }
}
};
},
afterCreate: async ({ organization, member }) => {
// Post-creation logic (e.g., send welcome email, create default resources)
await createDefaultResources(organization.id);
},
beforeDelete: async ({ organization }) => {
// Cleanup before deletion
await archiveOrganizationData(organization.id);
}
},
member: {
afterCreate: async ({ member, organization }) => {
await notifyAdmins(organization.id, `New member joined`);
}
},
invitation: {
afterCreate: async ({ invitation, organization, inviter }) => {
await logInvitation(invitation);
}
}
}
});
```
## Schema Customization
Customize table names, field names, and add additional fields:
```ts
organization({
schema: {
organization: {
modelName: 'workspace', // Rename table
fields: {
name: 'workspaceName' // Rename fields
},
additionalFields: {
billingId: {
type: 'string',
required: false
}
}
},
member: {
additionalFields: {
department: {
type: 'string',
required: false
},
title: {
type: 'string',
required: false
}
}
}
}
});
```
## Security Considerations
### Owner Protection
- The last owner cannot be removed from an organization
- The last owner cannot leave the organization
- The owner role cannot be removed from the last owner
Always ensure ownership transfer before removing the current owner:
```ts
// Transfer ownership first
await authClient.organization.updateMemberRole({
memberId: 'new-owner-member-id',
role: 'owner'
});
// Then the previous owner can be demoted or removed
```
### Organization Deletion
Deleting an organization removes all associated data (members, invitations, teams). Prevent accidental deletion:
```ts
organization({
disableOrganizationDeletion: true // Disable via config
});
```
Or implement soft delete via hooks:
```ts
organization({
hooks: {
organization: {
beforeDelete: async ({ organization }) => {
// Archive instead of delete
await archiveOrganization(organization.id);
throw new Error('Organization archived, not deleted');
}
}
}
});
```
### Invitation Security
- Invitations expire after 48 hours by default
- Only the invited email address can accept an invitation
- Pending invitations can be cancelled by organization admins
## Complete Configuration Example
```ts
import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';
import { sendEmail } from './email';
export const auth = betterAuth({
plugins: [
organization({
// Organization limits
allowUserToCreateOrganization: true,
organizationLimit: 10,
membershipLimit: 100,
creatorRole: 'owner',
// Slugs
defaultOrganizationIdField: 'slug',
// Invitations
invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days
invitationLimit: 50,
sendInvitationEmail: async (data) => {
await sendEmail({
to: data.email,
subject: `Join ${data.organization.name}`,
html: `<a href="https://app.com/invite/${data.invitation.id}">Accept</a>`
});
},
// Hooks
hooks: {
organization: {
afterCreate: async ({ organization }) => {
console.log(`Organization ${organization.name} created`);
}
}
}
})
]
});
```
@@ -0,0 +1,331 @@
---
name: two-factor-authentication-best-practices
description: Configure TOTP authenticator apps, send OTP codes via email/SMS, manage backup codes, handle trusted devices, and implement 2FA sign-in flows using Better Auth's twoFactor plugin. Use when users need MFA, multi-factor authentication, authenticator setup, or login security with Better Auth.
---
## Setup
1. Add `twoFactor()` plugin to server config with `issuer`
2. Add `twoFactorClient()` plugin to client config
3. Run `npx @better-auth/cli migrate`
4. Verify: check that `twoFactorSecret` column exists on user table
```ts
import { betterAuth } from 'better-auth';
import { twoFactor } from 'better-auth/plugins';
export const auth = betterAuth({
appName: 'My App',
plugins: [
twoFactor({
issuer: 'My App'
})
]
});
```
### Client-Side Setup
```ts
import { createAuthClient } from 'better-auth/client';
import { twoFactorClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [
twoFactorClient({
onTwoFactorRedirect() {
window.location.href = '/2fa';
}
})
]
});
```
## Enabling 2FA for Users
Requires password verification. Returns TOTP URI (for QR code) and backup codes.
```ts
const enable2FA = async (password: string) => {
const { data, error } = await authClient.twoFactor.enable({
password
});
if (data) {
// data.totpURI — generate a QR code from this
// data.backupCodes — display to user
}
};
```
`twoFactorEnabled` is not set to `true` until first TOTP verification succeeds. Override with `skipVerificationOnEnable: true` (not recommended).
## TOTP (Authenticator App)
### Displaying the QR Code
```tsx
import QRCode from 'react-qr-code';
const TotpSetup = ({ totpURI }: { totpURI: string }) => {
return <QRCode value={totpURI} />;
};
```
### Verifying TOTP Codes
Accepts codes from one period before/after current time:
```ts
const verifyTotp = async (code: string) => {
const { data, error } = await authClient.twoFactor.verifyTotp({
code,
trustDevice: true
});
};
```
### TOTP Configuration Options
```ts
twoFactor({
totpOptions: {
digits: 6, // 6 or 8 digits (default: 6)
period: 30 // Code validity period in seconds (default: 30)
}
});
```
## OTP (Email/SMS)
### Configuring OTP Delivery
```ts
import { betterAuth } from 'better-auth';
import { twoFactor } from 'better-auth/plugins';
import { sendEmail } from './email';
export const auth = betterAuth({
plugins: [
twoFactor({
otpOptions: {
sendOTP: async ({ user, otp }, ctx) => {
await sendEmail({
to: user.email,
subject: 'Your verification code',
text: `Your code is: ${otp}`
});
},
period: 5, // Code validity in minutes (default: 3)
digits: 6, // Number of digits (default: 6)
allowedAttempts: 5 // Max verification attempts (default: 5)
}
})
]
});
```
### Sending and Verifying OTP
Send: `authClient.twoFactor.sendOtp()`. Verify: `authClient.twoFactor.verifyOtp({ code, trustDevice: true })`.
### OTP Storage Security
Configure how OTP codes are stored in the database:
```ts
twoFactor({
otpOptions: {
storeOTP: 'encrypted' // Options: "plain", "encrypted", "hashed"
}
});
```
For custom encryption:
```ts
twoFactor({
otpOptions: {
storeOTP: {
encrypt: async (token) => myEncrypt(token),
decrypt: async (token) => myDecrypt(token)
}
}
});
```
## Backup Codes
Generated automatically when 2FA is enabled. Each code is single-use.
### Displaying Backup Codes
```tsx
const BackupCodes = ({ codes }: { codes: string[] }) => {
return (
<div>
<p>Save these codes in a secure location:</p>
<ul>
{codes.map((code, i) => (
<li key={i}>{code}</li>
))}
</ul>
</div>
);
};
```
### Regenerating Backup Codes
Invalidates all previous codes:
```ts
const regenerateBackupCodes = async (password: string) => {
const { data, error } = await authClient.twoFactor.generateBackupCodes({
password
});
// data.backupCodes contains the new codes
};
```
### Using Backup Codes for Recovery
```ts
const verifyBackupCode = async (code: string) => {
const { data, error } = await authClient.twoFactor.verifyBackupCode({
code,
trustDevice: true
});
};
```
### Backup Code Configuration
```ts
twoFactor({
backupCodeOptions: {
amount: 10, // Number of codes to generate (default: 10)
length: 10, // Length of each code (default: 10)
storeBackupCodes: 'encrypted' // Options: "plain", "encrypted"
}
});
```
## Handling 2FA During Sign-In
Response includes `twoFactorRedirect: true` when 2FA is required:
### Sign-In Flow
1. Call `signIn.email({ email, password })`
2. Check `context.data.twoFactorRedirect` in `onSuccess`
3. If `true`, redirect to `/2fa` verification page
4. Verify via TOTP, OTP, or backup code
5. Session cookie is created on successful verification
```ts
const signIn = async (email: string, password: string) => {
const { data, error } = await authClient.signIn.email(
{ email, password },
{
onSuccess(context) {
if (context.data.twoFactorRedirect) {
window.location.href = '/2fa';
}
}
}
);
};
```
Server-side: check `"twoFactorRedirect" in response` when using `auth.api.signInEmail`.
## Trusted Devices
Pass `trustDevice: true` when verifying. Default trust duration: 30 days (`trustDeviceMaxAge`). Refreshes on each sign-in.
## Security Considerations
### Session Management
Flow: credentials → session removed → temporary 2FA cookie (10 min default) → verify → session created.
```ts
twoFactor({
twoFactorCookieMaxAge: 600 // 10 minutes in seconds (default)
});
```
### Rate Limiting
Built-in: 3 requests per 10 seconds for all 2FA endpoints. OTP has additional attempt limiting:
```ts
twoFactor({
otpOptions: {
allowedAttempts: 5 // Max attempts per OTP code (default: 5)
}
});
```
### Encryption at Rest
TOTP secrets: encrypted with auth secret. Backup codes: encrypted by default. OTP: configurable (`"plain"`, `"encrypted"`, `"hashed"`). Uses constant-time comparison for verification.
2FA can only be enabled for credential (email/password) accounts.
## Disabling 2FA
Requires password confirmation. Revokes trusted device records:
```ts
const disable2FA = async (password: string) => {
const { data, error } = await authClient.twoFactor.disable({
password
});
};
```
## Complete Configuration Example
```ts
import { betterAuth } from 'better-auth';
import { twoFactor } from 'better-auth/plugins';
import { sendEmail } from './email';
export const auth = betterAuth({
appName: 'My App',
plugins: [
twoFactor({
// TOTP settings
issuer: 'My App',
totpOptions: {
digits: 6,
period: 30
},
// OTP settings
otpOptions: {
sendOTP: async ({ user, otp }) => {
await sendEmail({
to: user.email,
subject: 'Your verification code',
text: `Your code is: ${otp}`
});
},
period: 5,
allowedAttempts: 5,
storeOTP: 'encrypted'
},
// Backup code settings
backupCodeOptions: {
amount: 10,
length: 10,
storeBackupCodes: 'encrypted'
},
// Session settings
twoFactorCookieMaxAge: 600, // 10 minutes
trustDeviceMaxAge: 30 * 24 * 60 * 60 // 30 days
})
]
});
```
-2
View File
@@ -21,5 +21,3 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/../generated/prisma
+1 -4
View File
@@ -4,10 +4,7 @@
"trailingComma": "none",
"semi": true,
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte",
"prettier-plugin-tailwindcss"
],
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
+27
View File
@@ -0,0 +1,27 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Backend (server-side code)",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"autoAttachChildProcesses": true
},
{
"name": "Frontend (client-side code)",
"type": "chrome",
"request": "launch",
"preLaunchTask": "Start Dev Server",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}",
"sourceMapPathOverrides": {
"vite:///src/*": "${webRoot}/src/*"
}
}
]
}
+1
View File
@@ -0,0 +1 @@
{}
+26
View File
@@ -0,0 +1,26 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Start Dev Server",
"type": "shell",
"command": "npm run dev -- --port 5173 --strictPort",
"isBackground": true,
"problemMatcher": {
"pattern": [
{
"regexp": "^.*$",
"file": 0,
"location": 0,
"message": 0
}
],
"background": {
"activeOnStart": true,
"beginsPattern": "^.*VITE.*v",
"endsPattern": "^.*localhost:.*$"
}
}
}
]
}
+407
View File
@@ -0,0 +1,407 @@
You're right — I gave you the optimized root content earlier, but I did not provide it cleanly as a single copy/paste artifact. Here it is.
Save this as:
```text
AGENTS.md
```
at your project root:
````text
# Rhemawave Portal AI Guide
## Mission
You are working on portal.Rhemawavetech.com.
Act as a senior engineer making complete, production-ready changes.
Do not stop after analysis.
Do not provide a plan and wait.
Do not describe what should be done instead of doing it.
When asked to make a change:
1. Inspect the relevant code.
2. Understand the existing implementation.
3. Make the change.
4. Validate the result.
5. Fix any problems introduced.
6. Report what changed and what was verified.
A task is incomplete until the implementation is finished and verified.
---
# Working Style
## Focused Context
Do not load the entire repository into context.
Use a focused approach:
1. Identify relevant files.
2. Read only necessary code.
3. Find existing patterns.
4. Make the smallest correct change.
Avoid:
- broad rewrites
- unrelated cleanup
- speculative refactors
- introducing unnecessary dependencies
Prefer:
- small diffs
- existing conventions
- explicit implementations
---
# Development Loop
Always follow this workflow:
## 1. Understand
Before editing:
- Locate relevant files.
- Inspect current behavior.
- Find similar working examples.
- Identify constraints.
## 2. Implement
Make the smallest correct change.
Follow existing architecture.
Do not replace working patterns with new abstractions unless required.
## 3. Verify
Run appropriate checks:
```sh
npm run check
npm run lint
npm run test
````
Use narrower commands when appropriate.
## 4. Complete
If validation fails:
* Diagnose the failure.
* Fix the issue.
* Run validation again.
Never finish with known failures.
---
# Project Overview
Application:
portal.Rhemawavetech.com
Stack:
* SvelteKit 2
* Svelte 5
* TypeScript
* Tailwind CSS v4
* Prisma 7
* PostgreSQL
* Better Auth
* Vitest
* Playwright
Package manager:
npm
---
# Quick Start
Install:
```sh
npm install
```
Generate types:
```sh
npm run prepare
```
Run development:
```sh
npm run dev
```
Required environment:
```text
DATABASE_URL
BETTER_AUTH_SECRET
BETTER_AUTH_URL
```
---
# Architecture Map
## Routes
Protected routes:
```text
(protected)/
```
Require authentication and profile.
User routes:
```text
(user)/
```
User-facing functionality.
Admin routes:
```text
admin/
```
Require:
```text
roleId <= 3
```
Roles:
```text
SUPER = 1
ADMIN = 2
STAFF = 3
USER = 4
```
---
# Database
Prisma generated client:
```text
src/lib/generated/prisma/
```
Do not import Prisma from:
```text
node_modules/.prisma/client
```
Database singleton:
```text
src/lib/db.ts
```
---
# Authentication
Server:
```text
src/lib/auth.ts
```
Browser:
```text
src/lib/auth-client.ts
```
Session handling:
```text
src/hooks.server.ts
```
---
# Admin Architecture
Admin forms are route-specific.
Example:
```text
admin/[model]/
ModelForm.svelte
```
Do not recreate deleted generic components:
```text
AdminForm.svelte
AdminPage.svelte
```
Registry metadata exists for:
* columns
* searches
* relations
Forms are manually implemented per route.
---
# Svelte Rules
This project uses Svelte 5 runes.
Use:
```text
$state()
$derived()
$effect()
$props()
```
Do not use:
```text
writable()
derived()
legacy stores
```
For Svelte-specific patterns, read:
```text
docs/ai/svelte-patterns.md
```
Only load that file when modifying Svelte components or Svelte logic.
---
# Testing Rules
Testing uses:
* Vitest
* Playwright browser testing
For testing patterns, read:
```text
docs/ai/testing-patterns.md
```
Only load that file when modifying tests.
---
# API Rules
For API routes, authentication, keys, or integrations, read:
```text
docs/ai/api.md
```
Only load that file when working on API functionality.
---
# Formatting
Prettier rules:
* tabs
* single quotes
* no trailing commas
* 80 character print width
Follow existing formatting.
---
# Important Commands
Check:
```sh
npm run check
```
Lint:
```sh
npm run lint
```
Format:
```sh
npm run format
```
Tests:
```sh
npm run test
```
Build:
```sh
npm run build
```
---
# Completion Standard
Before claiming a task is complete:
Confirm:
1. Code changes are implemented.
2. Relevant tests/checks were run.
3. Failures were fixed.
4. The final response explains:
* what changed
* files modified
* verification performed
Do not stop at:
* "I found the issue"
* "Here is a plan"
* "The next step would be"
Finish the work.
+363
View File
@@ -0,0 +1,363 @@
# portal.Rhemawavetech.com
SvelteKit 2 + Svelte 5 (runes) + Tailwind CSS v4 + Prisma 7 + PostgreSQL + Better-Auth.
## Quick start
```sh
npm install # engine-strict=true in .npmrc
npm run prepare # svelte-kit sync (generate types)
npm run dev # vite dev on :5173
```
Requires `.env` with at least `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`.
## Commands
| npm run ... | What it does |
| -------------------------- | ------------------------------------------------ |
| `check` | `svelte-kit sync && svelte-check` (typecheck) |
| `lint` | `prettier --check . && eslint .` (order matters) |
| `format` | `prettier --write .` |
| `test` | `vitest --run` (single run) |
| `test:unit` | `vitest` (watch mode) |
| `migrate -- --name <name>` | Create a Prisma migration |
| `deploy` | Apply pending migrations |
| `build` | Production build (adapter-node) |
## Testing quirks
- **Two Vitest projects**: `client` (Playwright/Chromium headless, `src/**/*.svelte.{test,spec}.{js,ts}`) and `server` (node env, everything else — `src/**/*.{test,spec}.{js,ts}` excluding `.svelte.*`). Tests co-located beside source files.
- **`expect: { requireAssertions: true }`** — every test must assert something. No assertions = test fails.
- **All route tests mock `$lib/db`** (Prisma client) and `$lib/auth-client`/`$lib/auth` via `vi.mock()`. Stub factories in `src/lib/test-utils.ts`.
- **No `.env.test`** — mocking `$lib/db` makes real DB unnecessary.
- **Mock `$app/forms`** for any component using `use:enhance` — provide `enhance: () => () => {}` (action → destroy fn).
- **Mock TipTap** for client tests rendering ProclamationForm — the Svelte 5 component mock pattern is a function `($anchor: Element) => { …; $anchor.replaceWith(div); }`.
## Architecture notes
- **Route groups**: `(protected)/` requires auth + profile; `(user)/` is user-facing; `admin/` requires roleId <= 3 (STAFF+).
- **Admin forms**: Each route has its own form component (e.g. `ProclamationForm.svelte`, `CategoryForm.svelte`). The generic `AdminForm.svelte` / `AdminPage.svelte` were **deleted**. Registry config (`registry.ts`) still provides metadata, list fields, search fields, relation options, etc., but forms are hand-written per route.
- **Prisma client** generated to `src/lib/generated/prisma/` (not `node_modules/.prisma/client`). Lazy singleton via Proxy in `src/lib/db.ts`.
- **Better-Auth** server instance in `src/lib/auth.ts`, browser client in `src/lib/auth-client.ts`. Session fetched in `src/hooks.server.ts`.
- **Roles**: integer hierarchy (SUPER=1, ADMIN=2, STAFF=3, USER=4). `canAccessAdmin` checks roleId <= 3.
- **Payments are stubbed** (`src/lib/payments.ts` — setTimeout + console.log).
- **API keys**: SHA-256 hashed before storage (`ApiKey` model); only prefix (11 chars) stored for identification. `/api/v1/*` requires `Authorization: Bearer <key>`, except `/api/v1/automations/` (GHL webhook, no API key needed). Rotation: normal (48h grace via `expiresAt`) or emergency (immediate via `revokedAt`). Super-only admin page at `/admin/api-keys`.
## Formatting conventions
- Tabs, single quotes, no trailing commas, 100 print width (enforced by Prettier).
- Svelte 5 runes only (`$state`, `$derived`, `$effect`, `$props`). No legacy stores (`writable`, `derived`, etc.).
---
## Svelte 5 Runes Gotchas & Patterns
### `$state` initialization vs `$effect` sync
State variables declared with `$state('')` are set once on component mount. To keep them in sync with a changing `$props()` value, use a **separate `$effect`**:
```svelte
<script lang="ts">
let { record = {} as Record<string, unknown> } = $props();
let name = $state('');
let freq = $state('');
// Sync local state when prop changes
$effect(() => {
name = String(record.name ?? '');
freq = String(record.solfeggioFrequencyId ?? '');
});
</script>
```
This is the **only correct pattern** for form fields that edit an existing record. Do NOT initialize `$state` directly from the prop — that only captures the initial value and won't re-sync when the parent changes the record (e.g. switching from one row to another in a modal).
### `$effect` ordering matters
Effects run in declaration order. If you have multiple effects, the first one's writes are applied before the second one runs. This matters for the `dirty` flag:
```svelte
let dirty = $state(false);
// Runs first — resets dirty on mount / record change
$effect(() => {
dirty = false;
});
// Runs second — syncs field values
$effect(() => {
name = String(record.name ?? '');
// …
});
```
Both effects run synchronously during mount, so by the time the browser paints, all state is consistent.
### `bind:value` on `<select>` is broken with `$effect`-driven state
**DO NOT USE** `bind:value={stateVar}` on `<select>` elements when the state is set from a `$effect`. Svelte 5's select binding does not respond to `$state` changes triggered inside `$effect`. Instead:
**Use `selected` on `<option>` + `onchange` on `<select>`:**
```svelte
<select name="solfeggioFrequencyId" onchange={(e) => freq = (e.target as HTMLSelectElement).value}>
<option value="">-- Select --</option>
{#each options as opt (opt.value)}
<option value={opt.value} selected={String(opt.value) === freq}>{opt.label}</option>
{/each}
</select>
```
This pattern applies to ALL `<select>` elements in form components. The `value={stateVar}` prop directive also does not work reliably — always use `selected` on options.
### `bind:value` on `<input>` works fine
Unlike `<select>`, `bind:value` on `<input type="text">` works correctly with `$effect`-driven state. Feel free to use it for text inputs.
### `$props()` destructuring
Always destructure `$props()` with defaults:
```svelte
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
isSuper = false,
}: { record?: Record<string, unknown>; … } = $props();
```
The type annotation on the destructuring object is REQUIRED for Svelte 5 to infer prop types. Default values are used when the parent doesn't pass that prop.
### Component mocking for client tests
When mocking a Svelte 5 component in `vi.mock`, use a function that takes `($anchor, props)`:
```ts
vi.mock('$lib/components/tiptap/TipTap.svelte', () => ({
default: ($anchor: Element, _props: Record<string, unknown>) => {
const div = document.createElement('div');
div.setAttribute('data-testid', 'tiptap-mock');
$anchor.replaceWith(div);
}
}));
```
---
## Testing Patterns & Gotchas
### What tests to write
For every admin route, write BOTH:
1. **Server test** (`page.server.spec.ts`): Test the `load` function and all `actions` (upsert, delete). Cover role-based access control (Super vs non-Super content stripping, rejection of unauthorized mutations). Mock `$lib/db` and `$lib/svelte-admin/crud`.
2. **Client test** (`Component.svelte.spec.ts`): Test the form component rendering in Playwright/Chromium. Cover:
- All fields render
- Pre-fill on edit (name input, select values)
- Conditional field visibility (e.g. content hidden for non-Super)
- Submit button disabled/enabled by dirty state
- No visual regression testing — only functional assertions.
### Available Playwright/Vitest Browser Locator API
These methods are available on `page` from `vitest/browser`:
| Method | Works? | Example |
| ------------------- | ------ | --------------------------------------------- |
| `getByRole` | ✅ | `page.getByRole('textbox', { name: 'Name' })` |
| `getByText` | ✅ | `page.getByText('Content')` |
| `getByPlaceholder` | ✅ | `page.getByPlaceholder('Email Address')` |
| `getByAltText` | ✅ | `page.getByAltText('Sign In with Google')` |
| `getByTestId` | ✅ | `page.getByTestId('tiptap-mock')` |
| `getByLabel` | ❌ | Not available in this version |
| `getByDisplayValue` | ❌ | Not available in this version |
For label-identified elements, use `getByRole` with `name` option:
- Text inputs → `getByRole('textbox', { name: /^Name$/ })`
- Selects → `getByRole('combobox', { name: /^Frequency$/ })`
- Buttons → `getByRole('button', { name: 'Save' })`
**Always use regex `{ name: /^Exact$/ }`** for role queries to avoid strict-mode violations from substring matches (e.g. `'Category'` matching both `'Category'` and `'Sub-Category'`).
### Querying select values in client tests
Since `bind:value` on selects doesn't work with `$effect`-driven state, and we use `selected` on `<option>` instead, you can check the select's value directly:
```ts
const el = (await page
.getByRole('combobox', { name: /^Frequency$/ })
.element()) as HTMLSelectElement;
expect(el.value).toBe('2');
```
The `.element()` call returns the underlying DOM element. This works synchronously because `selected` is set during render.
### Server test patterns
**Always call `vi.clearAllMocks()` in `beforeEach`.**
Mock `$lib/db` with the exact Prisma model methods the code under test uses:
```ts
vi.mock('$lib/db', () => ({
prisma: {
proclamation: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
},
proclamationCategory: { findMany: vi.fn() }
}
}));
```
Mock `$lib/svelte-admin/crud` when the route uses custom upsert/delete logic:
```ts
vi.mock('$lib/svelte-admin/crud', () => ({
loadRecords: vi.fn(),
deleteRecord: vi.fn(),
superFormErrorsToFieldErrors: vi.fn(() => ({ error: '', fieldErrors: {} }))
}));
```
Use `createLocals(mockUser({ roleId: 4 }) as any)` to simulate different user roles.
Use `createFormRequest({ name: 'test' })` for form submissions.
Use `.catch((e) => e)` + `isHttpError()` for error assertions.
### `createLocals` nuances
- `createLocals()` (no arg) → unauthenticated (session is `undefined`).
- `createLocals(mockUser() as any)` → authenticated with the mock user object.
- Cast to `any` when passing to load/action functions: `{ locals: createLocals(mockUser()) } as any`.
### `vi.mock` hoisting
All `vi.mock()` calls must be at the top level of the test file. Values needed inside `vi.mock` factories must use `vi.hoisted()`:
```ts
const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }));
vi.mock('$lib/some-module', () => ({ default: mockFn }));
```
Imports of the mocked module must come AFTER `vi.mock()` calls.
---
## Admin Form Architecture
### Per-route form components
Each admin route under `admin/[model]/` has its own form component (e.g. `ProclamationForm.svelte`). These components:
- Receive `record`, `fieldErrors`, `formError`, `relationOptionsData`, and model-specific extras (e.g. `categories`, `isSuper`) as props.
- Use `$state` + `$effect` for field values (see "Svelte 5 Runes Gotchas" above).
- Use `selected` on `<option>` for selects.
- Use `oninput` on the `<form>` to track dirty state: `<form oninput={markDirty}>`.
- Submit via `action="?/upsert"` with SvelteKit's `use:enhance`.
### Server actions
Routes with custom requirements (like proclamation's Super-only restriction) override the generic `upsertRecord`/`deleteRecord` with their own actions. The generic functions in `crud.ts` are still available and used by simpler routes.
### Super-only proclamation restriction
- **Load**: content field is stripped from records for non-Super users before returning.
- **Upsert**: returns `{ success: false, error: 'Only Super admins can edit proclamations' }` for non-Super.
- **Delete**: same early return for non-Super; delegates to `deleteRecord` for Super.
- **Content field in form**: wrapped in `{#if isSuper}` block — hidden from non-Super users.
- **Row click / New button**: disabled for non-Super in `+page.svelte`.
### Dirty-state submit pattern
When editing an existing record (`record.id` is truthy), the submit button is disabled until the user modifies at least one field. The form tracks dirtiness with `let dirty = $state(false)` and a `markDirty()` handler:
```svelte
<form oninput={markDirty}>
<button disabled={!!record?.id && !dirty}>Save</button>
</form>
```
The `$effect` resets `dirty = false` on mount (when the record prop changes).
---
## Generated / build artifacts
- `.svelte-kit/` — SvelteKit generated types (gitignored)
- `src/lib/generated/prisma/` — Prisma client output (not gitignored; regenerate with `npx prisma generate`)
---
## API v1 Endpoints
| Route | Method | Auth | Description |
| ------------------------------------ | ------ | -------- | ------------------------------------------------ |
| `/api/v1/categories` | GET | API key | List all proclamation categories |
| `/api/v1/frequencies` | GET | API key | List all solfeggio frequencies |
| `/api/v1/proclamations` | GET | API key | List proclamations (with optional `?key` + `?email` filter) |
| `/api/v1/license` | POST | API key | Validate key: `{ license_key, email_address }` |
| `/api/v1/automations/create/account` | POST | None | GHL webhook: create user + license key + emails |
## GHL Webhook — Auto Account Creation
`POST /api/v1/automations/create/account` — excluded from API key auth in `hooks.server.ts`.
**Payload:**
```json
{ "email": "...", "name": "...", "subscription_level": "Basic" }
```
`subscription_level` must match a `SubscriptionLevel.name` in the database.
**Flow:**
1. If user email doesn't exist → `auth.api.signUpEmail()` with random temp password
2. Query `Verification` table for the auto-generated verification token → construct verify URL
3. Send `automated-user-registration` Resend template (variables: `firstName`, `tempPassword`, `verifyUrl`, `loginUrl`)
4. Create `LicenseKey` record (`active: true`, `level: subscription_level`)
5. Send `license-key-delivery` Resend template (variables: `firstName`, `license_key`)
6. If user already exists → skip signup, just create key + send delivery email
**Better Auth email callbacks** (`sendVerificationEmail`, `sendResetPassword`) use `void` + `.catch()` — template send failures are logged, never crash the server.
## Resend Email Templates
| Template ID | Variables | Subject (API-level) |
| ------------------------------ | --------------------------------------------------------------- | ------------------------------ |
| `automated-user-registration` | `firstName`, `tempPassword`, `verifyUrl`, `loginUrl` | "Your account is ready" |
| `license-key-delivery` | `firstName`, `license_key` | "Your Rhemawave License Key" |
| `email-verification-email` | `target_email_address`, `verification_link` | "Verify your email address" |
Variables must be declared in the Resend template editor's Variables section. Undeclared variables render as literal `{{{varName}}}` text.
Controlled by `src/lib/server/resend.ts` (`sendMail`) and `src/lib/server/email-verification.ts`.
## AdminTable Drag-and-Drop
AdminTable supports full-`<tr>` drag-and-drop reorder via props:
- `draggable: boolean` — enables drag handles on rows
- `onReorder: (ids: string[]) => Promise<void>` — called after drop with new row ID order
- `copyableFields: string[]` — field keys to show a copy-to-clipboard icon
**Pattern:** `$derived` + `dragOverride` — no `$effect` sync. `handleDragOver` always calls `preventDefault()` for reliable drop targets. `handleDrop` awaits `onReorder` then calls `invalidateAll()`.
See `src/lib/components/admin/AdminTable.svelte` and `src/routes/(protected)/admin/proclamation-categories/+page.svelte` for example usage.
## License Key Admin
- **Create**: `/admin/license-keys` form → `?/createKey` action. Requires email + subscription level. Generates key, persists to DB, emails the key via Resend template.
- **Replace**: Table row action → `?/replace` action. Deletes old key, creates new one with same user + level. No email sent.
- **Subscription level** stored as `SubscriptionLevel.name` string (not a FK).
## Key Generation
- **License keys**: 16 uppercase hex chars in `XXXX-XXXX-XXXX-XXXX` format. `crypto.randomUUID()` → strip dashes → uppercase → 4x4 chunks. Level stored as `SubscriptionLevel.name` string (not FK).
- **API keys**: `sk_` + 64 random hex chars. SHA-256 hashed for storage; first 11 chars stored as `keyPrefix` for identification.
+25 -3
View File
@@ -1,8 +1,12 @@
# Radiant Wave Technologies - User Portal
# Rhema Wave Technologies - User Portal
A combination application that combines the main Radiant Wave API with a user-facing account management application,
and an admin-facing platform management application. An all-in-one management platform for Radiant Wave and Radiant Wave Technologies.
SvelteKit 2 + Svelte 5 + Tailwind CSS v4 + Prisma 7 + PostgreSQL + Better-Auth.
## Deployment
This application runs inside of a Docker container, defined by the Dockerfile found within this repo.
Codeberg/Gitea actions are triggered on `push` actions to the `main` branch, and build the docker container.
The docker container is then uploaded to the Digital Ocean Container Registry, where it is pulled into an
@@ -11,13 +15,13 @@ App Platform application and run.
To build the Docker container locally (with podman), ensuring builds pass prior to pushing to `main`:
```sh
podman build --platform linux/amd64 -t registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest .
podman build --platform linux/amd64 -t registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest .
```
To build the Docker container on a Linux machine, such as a Forgejo Action Runner:
```sh
docker build -t registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest .
docker build -t registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest .
```
Then push to the container registry:
@@ -29,3 +33,21 @@ podman push registry.digitalocean.com/radiantwave-portal-container-registry/port
---
## Development
## Admin
Admin CRUD pages at `/admin`. 9 models have per-page routes with modals for create/edit:
| Included | Excluded |
| ------------------------ | --------------------------------- |
| users | Session (Better Auth) |
| profiles | Account (Better Auth) |
| addresses | Verification (Better Auth) |
| user-roles | UserProclamations (composite key) |
| proclamations | |
| solfeggio-frequencies | |
| proclamation-categories | |
| license-keys (list-only) | |
| subscription-levels | |
See `src/lib/svelte-admin/README.md` for architecture and configuration docs.
+235
View File
@@ -0,0 +1,235 @@
# API Documentation
Only read this file when modifying API routes, authentication, keys, or integrations.
---
# API Location
API routes:
src/routes/api/v1/
---
# Authentication
Most API endpoints require:
Authorization: Bearer <API key>
API keys are:
- generated randomly
- hashed with SHA-256 before storage
- displayed using stored prefix only
Never store plaintext API keys.
---
# API Key Lifecycle
Normal rotation:
- New key generated.
- Old key remains valid for 48 hours.
- Old key expires using expiresAt.
Emergency rotation:
- Old key immediately invalidated.
- revokedAt is set.
---
# API Routes
## Categories
GET:
/api/v1/categories
Requires API key.
---
## Frequencies
GET:
/api/v1/frequencies
Requires API key.
---
## Proclamations
GET:
/api/v1/proclamations
Supports:
- optional key filtering
- optional email filtering
Requires API key.
---
## License Validation
POST:
/api/v1/license
Payload:
{
"license_key": "...",
"email_address": "..."
}
Requires API key.
---
# GHL Account Creation Webhook
Endpoint:
POST /api/v1/automations/create/account
This endpoint does NOT require an API key.
It is used by GoHighLevel automation.
---
# Account Creation Flow
When a request arrives:
1. Check whether the email already exists.
If user does not exist:
2. Create user through Better Auth.
3. Generate temporary password.
4. Retrieve verification token.
5. Build verification URL.
6. Send registration email.
Then:
7. Create LicenseKey.
8. Send license key delivery email.
If user already exists:
1. Skip signup.
2. Create license key.
3. Send delivery email.
---
# Email Templates
Controlled by:
src/lib/server/resend.ts
Templates:
automated-user-registration
Variables:
- firstName
- tempPassword
- verifyUrl
- loginUrl
license-key-delivery
Variables:
- firstName
- license_key
email-verification-email
Variables:
- target_email_address
- verification_link
---
# Better Auth
Server:
src/lib/auth.ts
Client:
src/lib/auth-client.ts
Email callbacks:
- sendVerificationEmail
- sendResetPassword
Failures should:
- log errors
- not crash requests
Use:
void promise.catch()
pattern.
---
# Security Rules
Never:
- expose plaintext API keys
- bypass authorization checks
- trust client role information
- skip validation
Always:
- validate input
- check permissions
- preserve existing security patterns
---
# API Change Workflow
Before modifying an endpoint:
1. Read the route.
2. Read related auth middleware.
3. Read database models involved.
4. Check existing tests.
After modifying:
Run:
npm run check
npm run test
Fix failures before completing.
+184
View File
@@ -0,0 +1,184 @@
# Svelte 5 Patterns and Rules
This document contains Svelte-specific implementation rules.
Only read this file when modifying Svelte components, routes, or Svelte-specific logic.
---
# Svelte Version
This project uses:
- SvelteKit 2
- Svelte 5
- runes mode
Always use:
- $state()
- $derived()
- $effect()
- $props()
Do not use legacy stores:
- writable()
- readable()
- derived()
---
# Component Props
Always destructure $props() with defaults.
Preferred pattern:
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
isSuper = false
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
isSuper?: boolean;
} = $props();
Do not assume props exist.
---
# State Synchronization
IMPORTANT:
Do not initialize $state from props when the prop can change.
Incorrect:
let name = $state(record.name);
This only captures the initial value.
Correct:
let name = $state('');
$effect(() => {
name = String(record.name ?? '');
});
Use $effect whenever component state needs to follow changing props.
---
# Effect Ordering
$effect blocks execute in declaration order.
When multiple effects modify related state:
1. Reset state first.
2. Populate state second.
3. Perform dependent calculations afterward.
Example:
let dirty = $state(false);
$effect(() => {
dirty = false;
});
$effect(() => {
name = String(record.name ?? '');
});
This ensures a new record starts clean.
---
# Select Inputs
IMPORTANT:
Do not use bind:value on select elements whose values are synchronized through $effect.
Incorrect:
<select bind:value={frequency}>
<option value="1">One</option>
</select>
This can fail to update correctly.
Use:
<select onchange={(e) => frequency = e.currentTarget.value}>
<option value="1" selected={frequency === '1'}>
One
</option>
</select>
Use selected on options.
---
# Input Elements
Text inputs are different.
This is acceptable:
<input bind:value={name} />
bind:value works correctly with normal input elements.
---
# Forms
Admin forms use:
- $state for field values
- $effect for prop synchronization
- oninput for dirty tracking
- use:enhance for submission
Example:
<form oninput={markDirty}>
<button disabled={!!record?.id && !dirty}>
Save
</button>
</form>
---
# Avoid Unnecessary State
Prefer:
$derived()
for values computed from other state.
Use:
$state()
only for values that change independently.
---
# Component Changes
Before modifying a component:
1. Read the entire component.
2. Find similar components.
3. Follow existing patterns.
4. Avoid introducing new abstractions unless necessary.
Small, consistent changes are preferred.
+241
View File
@@ -0,0 +1,241 @@
# Testing Patterns
Only read this file when creating or modifying tests.
This project uses:
- Vitest
- Playwright browser testing
- Svelte Testing Library patterns through vitest/browser
---
# Test Philosophy
Tests should prove behavior.
Do not write tests that only confirm implementation details.
Every test must contain meaningful assertions.
A test without assertions fails because:
expect.requireAssertions is enabled.
---
# Test Locations
Tests are colocated with source files.
Client tests:
*.svelte.test.ts
*.svelte.spec.ts
Server tests:
*.test.ts
*.spec.ts
---
# Vitest Projects
There are two test environments.
## Client
Used for Svelte components.
Environment:
Playwright Chromium browser.
Used for:
- rendering
- user interaction
- form behavior
---
## Server
Used for:
- load functions
- actions
- server utilities
Environment:
Node.
---
# Before Each Test
Always reset mocks:
beforeEach(() => {
vi.clearAllMocks();
});
---
# Server Tests
Server route tests should cover:
- authenticated behavior
- unauthenticated behavior
- authorization rules
- successful operations
- failed operations
Mock:
- $lib/db
- authentication helpers
- external services
---
# Prisma Mocking
Mock only the methods used.
Example:
vi.mock('$lib/db', () => ({
prisma: {
proclamation: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
}
}
}));
Do not create massive unused mocks.
---
# Authentication Testing
Use:
createLocals()
Examples:
Unauthenticated:
createLocals()
Authenticated:
createLocals(mockUser() as any)
Specific roles:
createLocals(
mockUser({ roleId: 4 }) as any
)
---
# Client Test Locators
Available:
getByRole
getByText
getByPlaceholder
getByAltText
getByTestId
Unavailable:
getByLabel
getByDisplayValue
---
# Role Queries
Always use exact regex names.
Preferred:
page.getByRole(
'textbox',
{ name: /^Name$/ }
)
Avoid:
page.getByRole(
'textbox',
{ name: 'Name' }
)
because substring matching can create strict-mode failures.
---
# Select Testing
Because selects use selected options instead of bind:value:
const element =
await page
.getByRole(
'combobox',
{ name: /^Frequency$/ }
)
.element();
expect(
(element as HTMLSelectElement).value
).toBe('2');
---
# Svelte Component Mocking
When mocking Svelte 5 components:
Use:
vi.mock('$lib/components/example.svelte', () => ({
default: ($anchor: Element) => {
const div = document.createElement('div');
$anchor.replaceWith(div);
}
}));
Imports using mocked modules must come after vi.mock().
If variables are needed inside mocks:
Use vi.hoisted().
---
# Test Completion Rule
After writing tests:
Run:
npm run test
If failures occur:
Fix them.
Do not report "tests added" without verifying they pass.
-9
View File
@@ -1,9 +0,0 @@
{
description = "RhemaWave Portal";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
outputs = { self, nixpkgs }: {
nixosModules.default = import ./nixos/module.nix;
};
}
+799 -381
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -45,6 +45,7 @@
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"tsx": "^4.23.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.6",
@@ -52,12 +53,18 @@
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tiptap/core": "^3.15.3",
"@tiptap/extension-color": "^3.27.3",
"@tiptap/extension-highlight": "^3.27.3",
"@tiptap/extension-text-style": "^3.27.3",
"@tiptap/starter-kit": "^3.15.3",
"better-auth": "^1.4.10",
"dotenv": "^17.2.3",
"pg": "^8.16.3"
"pg": "^8.16.3",
"resend": "^6.17.1",
"sveltekit-superforms": "^2.30.2",
"zod": "^4.4.3"
}
}
+2 -1
View File
@@ -6,7 +6,8 @@ import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations'
path: 'prisma/migrations',
seed: 'tsx prisma/seed.ts'
},
datasource: {
url: process.env['DATABASE_URL']
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Profile" ADD COLUMN "ghl_contact_id" TEXT;
@@ -0,0 +1,5 @@
-- AlterTable: Remove price from Proclamation
ALTER TABLE "Proclamation" DROP COLUMN "price";
-- AlterTable: Change SolfeggioFrequency value from Float to Int
ALTER TABLE "SolfeggioFrequency" ALTER COLUMN "value" SET DATA TYPE INTEGER;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Address" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "type" TEXT NOT NULL DEFAULT 'Mailing';
@@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `isDefault` on the `Address` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Address" DROP COLUMN "isDefault",
ADD COLUMN "isDefaultBilling" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "isDefaultMailing" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "isDefaultShipping" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,19 @@
-- DropIndex
DROP INDEX "Proclamation_subscriptionLevelId_idx";
-- AlterTable
ALTER TABLE "Proclamation" ALTER COLUMN "subscriptionLevelId" DROP DEFAULT;
-- CreateTable
CREATE TABLE "ProclamationCategorySubscriptionLevel" (
"categoryId" INTEGER NOT NULL,
"subscriptionLevelId" INTEGER NOT NULL,
CONSTRAINT "ProclamationCategorySubscriptionLevel_pkey" PRIMARY KEY ("categoryId","subscriptionLevelId")
);
-- AddForeignKey
ALTER TABLE "ProclamationCategorySubscriptionLevel" ADD CONSTRAINT "ProclamationCategorySubscriptionLevel_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "ProclamationCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProclamationCategorySubscriptionLevel" ADD CONSTRAINT "ProclamationCategorySubscriptionLevel_subscriptionLevelId_fkey" FOREIGN KEY ("subscriptionLevelId") REFERENCES "SubscriptionLevel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ProclamationCategory" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"keyPrefix" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
+29 -5
View File
@@ -42,6 +42,7 @@ model Profile {
firstName String
lastName String
phoneNumber String
ghlContactId String? @map("ghl_contact_id")
addresses Address[]
}
@@ -54,6 +55,10 @@ model Address {
state String
postalCode String
country String
type String @default("Mailing")
isDefaultMailing Boolean @default(false)
isDefaultBilling Boolean @default(false)
isDefaultShipping Boolean @default(false)
}
model Session {
@@ -118,12 +123,9 @@ model Proclamation {
solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id])
categoryId Int
category ProclamationCategory @relation(fields: [categoryId], references: [id])
price Float @default(0.99)
userProclamations UserProclamations[]
subscriptionLevelId Int @default(1)
subscriptionLevelId Int // Keeping old FK for migration stability
subscriptionLevel SubscriptionLevel @relation(fields: [subscriptionLevelId], references: [id])
@@index(subscriptionLevelId)
}
model UserProclamations {
@@ -140,21 +142,33 @@ model UserProclamations {
model SolfeggioFrequency {
id Int @id @default(autoincrement())
name String @unique
value Float @unique
value Int @unique
proclamations Proclamation[]
}
model ProclamationCategory {
id Int @id @default(autoincrement())
name String
sortOrder Int @default(0)
parentId Int?
parent ProclamationCategory? @relation("CategoryHierarchy", fields: [parentId], references: [id])
children ProclamationCategory[] @relation("CategoryHierarchy")
proclamations Proclamation[]
subscriptionLevels ProclamationCategorySubscriptionLevel[]
@@unique([parentId, name])
}
// Join table for ProclamationCategory -> SubscriptionLevel defaults
model ProclamationCategorySubscriptionLevel {
categoryId Int
category ProclamationCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
subscriptionLevelId Int
subscriptionLevel SubscriptionLevel @relation(fields: [subscriptionLevelId], references: [id], onDelete: Cascade)
@@id([categoryId, subscriptionLevelId])
}
model LicenseKey {
id String @id @default(uuid())
key String @unique
@@ -164,9 +178,19 @@ model LicenseKey {
user User @relation(fields: [userId], references: [id])
}
model ApiKey {
id String @id @default(cuid())
keyPrefix String
keyHash String @unique
createdAt DateTime @default(now())
expiresAt DateTime?
revokedAt DateTime?
}
model SubscriptionLevel {
id Int @id @default(autoincrement())
name String
description String?
proclamations Proclamation[]
categoryLevels ProclamationCategorySubscriptionLevel[]
}
+111
View File
@@ -0,0 +1,111 @@
import 'dotenv/config';
import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/lib/generated/prisma/client';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
const userRoles = [
{ id: 1, name: 'Super' },
{ id: 2, name: 'Admin' },
{ id: 3, name: 'Staff' },
{ id: 4, name: 'User' }
];
const solfeggioFrequencies = [
{ id: 1, name: 'Crown', value: 936 },
{ id: 2, name: '3rd Eye', value: 852 },
{ id: 3, name: 'Throat', value: 741 },
{ id: 4, name: 'Heart', value: 639 },
{ id: 5, name: 'Solar Plexus', value: 528 },
{ id: 6, name: 'Sacral', value: 417 },
{ id: 7, name: 'Root', value: 396 }
];
const subscriptionLevels = [
{ id: 1, name: 'Basic', description: 'General only' },
{ id: 2, name: 'Advanced', description: 'General + Specific' },
{ id: 3, name: 'Elite', description: 'General + Specific + Precise' }
];
const proclamationCategories = [
{ id: 4, name: 'Spiritual', parentId: null },
{ id: 5, name: 'General', parentId: 4 },
{ id: 9, name: 'Specific', parentId: 4 },
{ id: 40, name: 'Precise', parentId: 4 },
{ id: 2, name: 'Physical', parentId: null },
{ id: 3, name: 'General', parentId: 2 },
{ id: 10, name: 'Specific', parentId: 2 },
{ id: 32, name: 'Precise', parentId: 2 },
{ id: 16, name: 'Mental', parentId: null },
{ id: 30, name: 'General', parentId: 16 },
{ id: 31, name: 'Specific', parentId: 16 },
{ id: 38, name: 'Precise', parentId: 16 },
{ id: 17, name: 'Custom (Client Specific)', parentId: null },
{ id: 19, name: 'Financial', parentId: null },
{ id: 28, name: 'General', parentId: 19 },
{ id: 29, name: 'Specific', parentId: 19 },
{ id: 37, name: 'Precise', parentId: 19 },
{ id: 20, name: 'Emotional', parentId: null },
{ id: 24, name: 'General', parentId: 20 },
{ id: 26, name: 'Specific', parentId: 20 },
{ id: 45, name: 'Precise', parentId: 20 },
{ id: 21, name: 'Social', parentId: null },
{ id: 22, name: 'General', parentId: 21 },
{ id: 23, name: 'Specific', parentId: 21 },
{ id: 39, name: 'Precise', parentId: 21 },
{ id: 41, name: 'Biblical', parentId: null },
{ id: 42, name: 'Personal Growth & Development', parentId: null },
{ id: 43, name: 'Prayers', parentId: 41 },
{ id: 44, name: 'Scripture', parentId: 41 },
{ id: 46, name: 'Sports', parentId: null }
];
async function main() {
console.log('Seeding UserRole...');
for (const role of userRoles) {
await prisma.userRole.upsert({
where: { id: role.id },
create: role,
update: role
});
}
console.log('Seeding SolfeggioFrequency...');
for (const freq of solfeggioFrequencies) {
await prisma.solfeggioFrequency.upsert({
where: { id: freq.id },
create: freq,
update: freq
});
}
console.log('Seeding ProclamationCategory...');
for (const cat of proclamationCategories) {
await prisma.proclamationCategory.upsert({
where: { id: cat.id },
create: cat,
update: cat
});
}
console.log('Seeding SubscriptionLevel...');
for (const level of subscriptionLevels) {
await prisma.subscriptionLevel.upsert({
where: { id: level.id },
create: level,
update: level
});
}
console.log('Seed complete.');
}
main()
.catch((e) => {
console.error('Seed failed:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+41
View File
@@ -0,0 +1,41 @@
{
"version": 1,
"skills": {
"better-auth-best-practices": {
"source": "better-auth/skills",
"sourceType": "github",
"skillPath": "better-auth/best-practices/SKILL.md",
"computedHash": "a4c830509e85557b59339d8d93a4e243e9e59c686e7678854d39230e12c2a6dc"
},
"better-auth-security-best-practices": {
"source": "better-auth/skills",
"sourceType": "github",
"skillPath": "security/SKILL.md",
"computedHash": "5717187bba9c22030c7174c74012f01c359f21e2ce87e0eb80e24244d8b8c915"
},
"create-auth-skill": {
"source": "better-auth/skills",
"sourceType": "github",
"skillPath": "better-auth/create-auth/SKILL.md",
"computedHash": "31c027d02dbf1faa37f58191f574b9443143042fb2b684fe32df53ab0270487c"
},
"email-and-password-best-practices": {
"source": "better-auth/skills",
"sourceType": "github",
"skillPath": "better-auth/emailAndPassword/SKILL.md",
"computedHash": "747d7f73bd9e2ec5317b8ef88dc70f97df716d00e3d02de4051feebaa48b062d"
},
"organization-best-practices": {
"source": "better-auth/skills",
"sourceType": "github",
"skillPath": "better-auth/organization/SKILL.md",
"computedHash": "79a5a85b43d10e9fe37582b3506b051a33a991817b938f349099baa5ddba21aa"
},
"two-factor-authentication-best-practices": {
"source": "better-auth/skills",
"sourceType": "github",
"skillPath": "better-auth/twoFactor/SKILL.md",
"computedHash": "7e297aaf887e11fdc03e52bdbf44974e161e8f7c393170ced7dc66cace4f6d46"
}
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
import type { Session, User } from "better-auth";
import type { Session, User } from 'better-auth';
declare global {
namespace App {
interface Locals {
session: Session,
user: User,
session: Session | null;
user: User | null;
}
}
}
+14 -3
View File
@@ -1,14 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
(function () {
const theme = localStorage.getItem('theme');
if (
theme === 'dark' ||
(!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
</script>
%sveltekit.head%
</head>
<body class="m-0" data-sveltekit-preload-data="hover">
<div class="h-dvh w-dvw flex flex-col">%sveltekit.body%</div>
<div class="flex h-dvh w-dvw flex-col">%sveltekit.body%</div>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { getSession } = vi.hoisted(() => ({
getSession: vi.fn()
}));
const { svelteKitHandler } = vi.hoisted(() => ({
svelteKitHandler: vi.fn()
}));
vi.mock('$app/environment', () => ({
building: false
}));
vi.mock('better-auth/svelte-kit', () => ({
svelteKitHandler
}));
vi.mock('$lib/auth', () => ({
auth: {
api: {
getSession
}
}
}));
vi.mock('$lib/server/api-key', () => ({
validateApiKey: vi.fn()
}));
import { handle } from './hooks.server';
function createMockEvent(path = '/dashboard') {
return {
request: new Request(`http://localhost${path}`),
url: new URL(`http://localhost${path}`),
locals: {} as Record<string, unknown>,
resolve: vi.fn()
} as any;
}
describe('hooks.server handle', () => {
beforeEach(() => {
getSession.mockReset();
svelteKitHandler.mockReset();
});
it('sets session and user on locals when authenticated', async () => {
const sessionData = {
session: { id: 'sess-1', userId: 'user-1', expiresAt: new Date() },
user: { id: 'user-1', name: 'Jane', email: 'jane@example.com' }
};
getSession.mockResolvedValue(sessionData);
const event = createMockEvent();
await handle({ event, resolve: event.resolve });
expect(event.locals.session).toEqual(sessionData.session);
expect(event.locals.user).toEqual(sessionData.user);
expect(svelteKitHandler).toHaveBeenCalledWith(
expect.objectContaining({
event,
resolve: event.resolve,
building: false
})
);
});
it('does not set locals when not authenticated', async () => {
getSession.mockResolvedValue(null);
const event = createMockEvent();
await handle({ event, resolve: event.resolve });
expect(event.locals.session).toBeUndefined();
expect(event.locals.user).toBeUndefined();
});
it('does not set locals when getSession returns undefined', async () => {
getSession.mockResolvedValue(undefined);
const event = createMockEvent();
await handle({ event, resolve: event.resolve });
expect(event.locals.session).toBeUndefined();
expect(event.locals.user).toBeUndefined();
});
it('passes building flag from $app/environment to svelteKitHandler', async () => {
getSession.mockResolvedValue(null);
const event = createMockEvent();
await handle({ event, resolve: event.resolve });
expect(svelteKitHandler).toHaveBeenCalledWith(expect.objectContaining({ building: false }));
});
it('calls svelteKitHandler with auth instance', async () => {
getSession.mockResolvedValue(null);
const event = createMockEvent();
await handle({ event, resolve: event.resolve });
expect(svelteKitHandler).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({
api: expect.objectContaining({ getSession })
})
})
);
});
});
+29 -6
View File
@@ -1,14 +1,37 @@
import { auth } from "$lib/auth";
import { svelteKitHandler } from "better-auth/svelte-kit";
import { building } from "$app/environment";
import { auth } from '$lib/auth';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { building } from '$app/environment';
import { validateApiKey } from '$lib/server/api-key';
export async function handle({ event, resolve }) {
// Fetch current session from Better Auth
if (
event.url.pathname.startsWith('/api/v1/') &&
!event.url.pathname.startsWith('/api/v1/automations/')
) {
const authHeader = event.request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'API key required' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
const key = authHeader.slice(7);
const record = await validateApiKey(key);
if (!record) {
return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
return resolve(event);
}
const session = await auth.api.getSession({
headers: event.request.headers,
headers: event.request.headers
});
// Make session and user available on server
if (session) {
event.locals.session = session.session;
event.locals.user = session.user;
+1 -7
View File
@@ -1,9 +1,3 @@
import { createAuthClient } from "better-auth/svelte"
import { createAuthClient } from 'better-auth/svelte';
export const authClient = createAuthClient({});
const signIn = async () => {
const data = await authClient.signIn.social({
provider: "google",
});
};
+43 -13
View File
@@ -1,26 +1,56 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "$lib/db";
import { sveltekitCookies } from "better-auth/svelte-kit";
import { getRequestEvent } from "$app/server";
import { env } from "$env/dynamic/private";
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { prisma } from '$lib/db';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from '$app/server';
import { sendMail } from './server/resend';
import { sendVerificationEmail } from './server/email-verification';
import { env } from '$env/dynamic/private';
export const auth = betterAuth({
baseURL: env.BETTER_AUTH_URL,
database: prismaAdapter(prisma, {
provider: "postgresql",
provider: 'postgresql'
}),
secret: env.BETTER_AUTH_SECRET,
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, token }) => {
void sendVerificationEmail({
email: user.email,
token
}).catch((e) => {
console.error('sendVerificationEmail template email failed:', e);
});
}
},
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
void sendMail({
to: user.email,
templateId: 'password-reset-email',
data: {
target_email_address: user.email,
reset_link: url
}
}).catch((e) => {
console.error('sendResetPassword template email failed:', e);
});
}
},
socialProviders: {
google: {
prompt: "select_account",
prompt: 'select_account',
clientId: env.GOOGLE_CLIENT_ID as string,
clientSecret: env.GOOGLE_CLIENT_SECRET,
clientSecret: env.GOOGLE_CLIENT_SECRET as string
}
},
plugins: [sveltekitCookies(getRequestEvent)],
})
rateLimit: {
enabled: true,
window: 60,
max: 100
},
trustedOrigins: ['http://localhost:5173', 'https://portal.rhemawavetech.com'],
plugins: [sveltekitCookies(getRequestEvent)]
});
@@ -0,0 +1,82 @@
<script lang="ts">
let {
open = false,
title = '',
onClose,
children,
canDelete = false,
recordId,
deleteAction = '?/delete'
}: {
open?: boolean;
title?: string;
onClose?: () => void;
children: import('svelte').Snippet;
canDelete?: boolean;
recordId?: string;
deleteAction?: string;
} = $props();
function handleBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) onClose?.();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose?.();
}
function confirmDelete() {
if (!recordId) return;
if (confirm('Are you sure you want to delete this record? This cannot be undone.')) {
const form = document.createElement('form');
form.method = 'POST';
form.action = deleteAction;
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'id';
input.value = recordId;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
}
}
</script>
{#if open}
<div
role="presentation"
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-12"
onclick={handleBackdrop}
onkeydown={handleKeydown}
>
<div
class="my-8 w-full max-w-2xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900"
>
<div
class="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700"
>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</h2>
<div class="flex items-center gap-2">
{#if canDelete && recordId}
<button
type="button"
onclick={confirmDelete}
class="px-3 py-1 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
Delete
</button>
{/if}
<button
type="button"
onclick={onClose}
class="px-2 py-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>&times;</button
>
</div>
</div>
<div class="px-6 py-4">
{@render children()}
</div>
</div>
</div>
{/if}
+248
View File
@@ -0,0 +1,248 @@
<script lang="ts">
import type { PrismaScalarType } from '$lib/svelte-admin/types';
import { fieldNameToLabel, resolveFieldValue } from '$lib/svelte-admin/introspect';
let {
records = [],
listFields = [],
searchFields = [],
fieldTypes = {} as Record<string, PrismaScalarType>,
singular = 'record',
draggable = false,
copyableFields = [] as string[],
onRowClick,
onNew,
onReorder
}: {
records: Record<string, unknown>[];
listFields: string[];
searchFields?: string[];
fieldTypes?: Record<string, PrismaScalarType>;
singular?: string;
draggable?: boolean;
copyableFields?: string[];
onRowClick?: (record: Record<string, unknown>) => void;
onNew?: () => void;
onReorder?: (ids: (string | number)[]) => void;
} = $props();
let search = $state('');
let sortCol = $state<string | null>(null);
let sortAsc = $state(true);
let filtered = $derived(
search
? records.filter((r) =>
searchFields.some((f) => {
const v = resolveFieldValue(r, f);
return v != null && String(v).toLowerCase().includes(search.toLowerCase());
})
)
: records
);
let sorted = $derived(
sortCol
? [...filtered].sort((a, b) => {
const va = resolveFieldValue(a, sortCol!);
const vb = resolveFieldValue(b, sortCol!);
if (va == null) return 1;
if (vb == null) return -1;
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true });
return sortAsc ? cmp : -cmp;
})
: filtered
);
let isDragging = $state(false);
let dragOverride = $state<Record<string, unknown>[] | null>(null);
let rows = $derived(dragOverride ?? sorted);
let dragIndex = $state<number | null>(null);
let hoverIndex = $state<number | null>(null);
function toggleSort(field: string) {
if (sortCol === field) {
sortAsc = !sortAsc;
} else {
sortCol = field;
sortAsc = true;
}
}
function handleDragStart(e: DragEvent, i: number) {
dragOverride = sorted;
isDragging = true;
dragIndex = i;
hoverIndex = i;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', '');
}
function handleDragOver(e: DragEvent, i: number) {
if (dragIndex === null) return;
e.preventDefault();
if (i === hoverIndex) return;
e.dataTransfer!.dropEffect = 'move';
const reordered = rows.slice();
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(i, 0, moved);
dragOverride = reordered;
dragIndex = i;
hoverIndex = i;
}
async function handleDrop() {
if (dragIndex === null) return;
const ids = rows.map((r) => r.id as string | number);
await onReorder?.(ids);
isDragging = false;
dragIndex = null;
hoverIndex = null;
}
function handleDragEnd() {
dragOverride = null;
isDragging = false;
dragIndex = null;
hoverIndex = null;
}
function copyValue(v: unknown) {
navigator.clipboard.writeText(String(v ?? ''));
}
function formatCellValue(v: unknown, field: string): string {
if (v == null) return '';
const ft = fieldTypes[field];
if (ft === 'DateTime') {
if (v instanceof Date) return v.toLocaleString();
return new Date(v as string).toLocaleString();
}
if (ft === 'Boolean') return v ? 'Active' : 'Inactive';
if (ft === 'Float') {
const n = Number(v);
return Number.isNaN(n) ? String(v) : n.toFixed(2);
}
return String(v);
}
</script>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-4">
{#if searchFields.length > 0}
<input
type="text"
placeholder="Search {singular}s..."
bind:value={search}
class="max-w-xs border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
{:else}
<div></div>
{/if}
{#if onNew}
<button
type="button"
onclick={onNew}
class="border border-gray-300 px-3 py-1.5 text-sm font-medium dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
>
New {singular} +
</button>
{/if}
</div>
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800">
{#if draggable}
<th scope="col" class="w-10 px-2 py-2"></th>
{/if}
{#each listFields as field (field)}
<th
scope="col"
onclick={() => toggleSort(field)}
class="cursor-pointer px-4 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
{fieldNameToLabel(field)}
{#if sortCol === field}
<span class="ml-1">{sortAsc ? '\u25B2' : '\u25BC'}</span>
{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as record, i ((record['id'] as string) ?? (record['key'] as string) ?? i)}
<tr
{draggable}
ondragstart={(e) => handleDragStart(e, i)}
ondragover={(e) => handleDragOver(e, i)}
ondrop={handleDrop}
ondragend={handleDragEnd}
onclick={() => onRowClick?.(record)}
class="border-b border-gray-200 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 {draggable &&
isDragging &&
dragIndex === i
? 'opacity-40'
: ''} {draggable && isDragging && hoverIndex === i
? 'border-t-2 border-t-blue-500'
: ''}"
>
{#if draggable}
<td
class="w-10 cursor-grab px-2 py-2 text-center"
onclick={(e) => e.stopPropagation()}
>
<span class="text-gray-400 select-none">⠿</span>
</td>
{/if}
{#each listFields as field (field)}
<td class="px-4 py-2 text-sm">
<span class="inline-flex items-center gap-1.5">
{formatCellValue(resolveFieldValue(record, field), field)}
{#if copyableFields.includes(field)}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
copyValue(resolveFieldValue(record, field));
}}
title="Copy to clipboard"
class="shrink-0 leading-none text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg
class="size-3.5"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3.5" y="2.5" width="11" height="11" rx="1" />
<path d="M5.5 2.5V1.5a1 1 0 011-1h3a1 1 0 011 1v1" />
</svg>
</button>
{/if}
</span>
</td>
{/each}
</tr>
{:else}
<tr>
<td
colspan={listFields.length + (draggable ? 1 : 0)}
class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
>
No {singular}s found.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
-160
View File
@@ -1,160 +0,0 @@
<script lang="ts">
type Option = { label: string; value: string | number };
type Props = {
id: string;
name: string;
options: Option[];
placeholder?: string;
value?: string | number;
};
let { id, name, options, placeholder = 'Search...', value }: Props = $props();
let search = $state('');
let open = $state(false);
let highlightIndex = $state(-1);
let inputEl = $state<HTMLInputElement | null>(null);
let listEl = $state<HTMLUListElement | null>(null);
let selectedValue = $state<string | number | undefined>(undefined);
$effect(() => {
if (value != null && value !== '') {
selectedValue = value;
}
});
let selectedLabel = $derived(() => {
if (selectedValue == null || selectedValue === '') return '';
const match = options.find((o) => String(o.value) === String(selectedValue));
return match?.label ?? '';
});
let filtered = $derived(() => {
if (!search) return options;
const q = search.toLowerCase();
return options.filter((o) => o.label.toLowerCase().includes(q));
});
function select(opt: Option) {
selectedValue = opt.value;
search = '';
open = false;
highlightIndex = -1;
}
function clear() {
selectedValue = undefined;
search = '';
open = true;
inputEl?.focus();
}
function handleInputFocus() {
open = true;
highlightIndex = -1;
}
function handleInputBlur(e: FocusEvent) {
const related = e.relatedTarget as HTMLElement | null;
if (related?.closest(`[data-combobox="${id}"]`)) return;
open = false;
highlightIndex = -1;
}
function handleKeydown(e: KeyboardEvent) {
const items = filtered();
if (e.key === 'ArrowDown') {
e.preventDefault();
open = true;
highlightIndex = Math.min(highlightIndex + 1, items.length - 1);
scrollToHighlighted();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIndex = Math.max(highlightIndex - 1, 0);
scrollToHighlighted();
} else if (e.key === 'Enter') {
e.preventDefault();
if (open && highlightIndex >= 0 && highlightIndex < items.length) {
select(items[highlightIndex]);
}
} else if (e.key === 'Escape') {
open = false;
search = '';
highlightIndex = -1;
}
}
function scrollToHighlighted() {
requestAnimationFrame(() => {
const el = listEl?.children[highlightIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
});
}
</script>
<div class="relative" data-combobox={id}>
<input type="hidden" {name} value={selectedValue ?? ''} />
<input type="hidden" name={name + 'Label'} value={selectedValue != null && selectedValue !== '' ? selectedLabel() : search} />
{#if selectedValue != null && selectedValue !== ''}
<div
class="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 transition-shadow outline-none focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500"
>
<button
type="button"
class="flex-1 cursor-pointer text-left text-gray-900"
onclick={() => clear()}
>
{selectedLabel()}
</button>
<button
type="button"
class="ml-2 shrink-0 text-gray-400 hover:text-gray-600"
onclick={() => clear()}
tabindex={-1}
>
&#10005;
</button>
</div>
{:else}
<input
type="text"
{id}
bind:this={inputEl}
bind:value={search}
{placeholder}
autocomplete="off"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
onfocus={handleInputFocus}
onblur={handleInputBlur}
onkeydown={handleKeydown}
/>
{/if}
{#if open}
<ul
bind:this={listEl}
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
role="listbox"
>
{#each filtered() as opt, i}
<li role="option" aria-selected={highlightIndex === i}>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm {highlightIndex === i
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-50'}"
onmousedown={(e: MouseEvent) => { e.preventDefault(); select(opt); }}
onmouseenter={() => (highlightIndex = i)}
>
{opt.label}
</button>
</li>
{:else}
<li class="px-3 py-2 text-sm text-gray-400">No matches</li>
{/each}
</ul>
{/if}
</div>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
let {
message = '',
type = 'success' as 'success' | 'error'
}: {
message?: string;
type?: 'success' | 'error';
} = $props();
let remaining = $state(5);
let visible = $state(true);
$effect(() => {
remaining = 5;
const timer = setInterval(() => {
remaining -= 1;
}, 1000);
return () => clearInterval(timer);
});
$effect(() => {
if (remaining <= 0) {
visible = false;
}
});
function dismiss() {
visible = false;
}
</script>
{#if visible}
<div
role="alert"
class="fixed bottom-4 left-4 z-50 flex min-w-64 items-center gap-3 border px-4 py-3 text-sm shadow-lg {type ===
'success'
? 'border-green-600 bg-green-50 text-green-800'
: 'border-red-600 bg-red-50 text-red-800'}"
>
<span class="flex-1">{message}</span>
<span class="text-xs tabular-nums opacity-70">{remaining}s</span>
<button type="button" onclick={dismiss} class="ml-1 leading-none font-bold hover:opacity-70"
>&times;</button
>
</div>
{/if}
@@ -1,7 +0,0 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="flex flex-1 flex-col items-center justify-center">
{@render children()}
</div>
+59 -28
View File
@@ -2,8 +2,12 @@
import { onMount } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Highlight from '@tiptap/extension-highlight';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
let { initialContent = '', name = 'content' } = $props<{ initialContent: string; name?: string }>();
let { initialContent = '', name = 'content' }: { initialContent?: string; name?: string } =
$props();
let element: HTMLElement;
let editor: Editor;
@@ -14,11 +18,20 @@
editor = new Editor({
element,
extensions: [StarterKit],
extensions: [
StarterKit.configure({
bulletList: false,
orderedList: false
}),
TextStyle,
Color,
Highlight.configure({ multicolor: true })
],
content: initialContent,
editorProps: {
attributes: {
class: 'outline-none h-full'
class:
'prose prose-sm max-w-none outline-none h-full min-h-40 p-3 dark:prose-invert dark:text-gray-100'
}
},
onUpdate: ({ editor }) => {
@@ -26,47 +39,65 @@
}
});
editor.setEditable(true);
return () => editor.destroy();
});
function toggleBold() {
editor?.chain().focus().toggleBold().run();
}
function toggleItalic() {
editor?.chain().focus().toggleItalic().run();
}
function toggleHighlight() {
editor?.chain().focus().toggleHighlight().run();
}
function setColor() {
const current = editor?.getAttributes('textStyle').color ?? '#000000';
const colors = ['#000000', '#FF0000', '#0000FF', '#008000', '#FFA500', '#800080', '#808080'];
const idx = colors.indexOf(current);
const next = colors[(idx + 1) % colors.length];
editor?.chain().focus().setColor(next).run();
}
</script>
<div class="border border-gray-300 rounded-lg overflow-hidden">
<div class="flex gap-1 p-2 bg-gray-100 border-b border-gray-300">
<div class="border border-gray-300 dark:border-gray-600">
<div
class="flex gap-1 border-b border-gray-300 bg-gray-100 p-2 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
>
<button
type="button"
onclick={() => editor.chain().focus().toggleBold().run()}
class="px-3 py-1 rounded font-bold hover:bg-gray-200 transition-colors"
>B</button>
onclick={toggleBold}
class="min-w-8 px-2 py-1 font-bold hover:bg-gray-200 dark:hover:bg-gray-700"
title="Bold">B</button
>
<button
type="button"
onclick={() => editor.chain().focus().toggleItalic().run()}
class="px-3 py-1 rounded italic hover:bg-gray-200 transition-colors"
>I</button>
onclick={toggleItalic}
class="min-w-8 px-2 py-1 italic hover:bg-gray-200 dark:hover:bg-gray-700"
title="Italic">I</button
>
<button
type="button"
onclick={() => editor.chain().focus().toggleStrike().run()}
class="px-3 py-1 rounded line-through hover:bg-gray-200 transition-colors"
>S</button>
<div class="w-px bg-gray-300 mx-1"></div>
onclick={toggleHighlight}
class="min-w-8 px-2 py-1 hover:bg-gray-200 dark:hover:bg-gray-700"
title="Highlight"><span class="bg-yellow-200 px-1 dark:bg-yellow-600">H</span></button
>
<button
type="button"
onclick={() => editor.chain().focus().toggleBulletList().run()}
class="px-3 py-1 rounded hover:bg-gray-200 transition-colors"
>• List</button>
<button
type="button"
onclick={() => editor.chain().focus().toggleOrderedList().run()}
class="px-3 py-1 rounded hover:bg-gray-200 transition-colors"
>1. List</button>
onclick={setColor}
class="min-w-8 px-2 py-1 hover:bg-gray-200 dark:hover:bg-gray-700"
title="Cycle text color">A</button
>
</div>
<div
bind:this={element}
role="textbox"
tabindex="-1"
class="p-3 min-h-40 prose prose-sm max-w-none cursor-text [&_.tiptap]:outline-none [&_.tiptap]:h-full [&_.tiptap_p]:my-1 [&_.tiptap_p:first-child]:mt-0 [&_.tiptap_p:last-child]:mb-0"
onmousedown={() => editor?.commands.focus()}
tabindex="0"
contenteditable="true"
class="min-h-40 cursor-text bg-white dark:bg-gray-900"
></div>
</div>
<input type="hidden" {name} value={content} />
@@ -1,27 +0,0 @@
<script lang="ts">
import { authClient } from '$lib/auth-client';
import { canAccessAdmin } from '$lib/svelte-admin/roles';
let { userRoleId = null }: { userRoleId: number | null } = $props();
const session = authClient.useSession();
let isAdmin = $derived(canAccessAdmin(userRoleId));
</script>
<nav class="flex w-full justify-between px-8 py-6">
<span><a href="/">Rhema Wave Technologies</a></span>
{#if $session.data}
<div>
{#if isAdmin}
<a href="/admin" class="mx-2">Admin Panel</a>
{/if}
<a href="/dashboard" class="mx-2">Dashboard</a>
<button
onclick={async () => {
authClient.signOut();
}}>Log Out</button
>
</div>
{:else}
<span><a href="/login">Log In</a> | <a href="/register">Register</a></span>
{/if}
</nav>
+27 -17
View File
@@ -1,4 +1,3 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
@@ -13,72 +12,83 @@
* 🟢 You can import this file directly.
*/
import * as Prisma from './internal/prismaNamespaceBrowser.ts'
export { Prisma }
export * as $Enums from './enums.ts'
import * as Prisma from './internal/prismaNamespaceBrowser.ts';
export { Prisma };
export * as $Enums from './enums.ts';
export * from './enums.ts';
/**
* Model User
*
*/
export type User = Prisma.UserModel
export type User = Prisma.UserModel;
/**
* Model Profile
*
*/
export type Profile = Prisma.ProfileModel
export type Profile = Prisma.ProfileModel;
/**
* Model Address
*
*/
export type Address = Prisma.AddressModel
export type Address = Prisma.AddressModel;
/**
* Model Session
*
*/
export type Session = Prisma.SessionModel
export type Session = Prisma.SessionModel;
/**
* Model Account
*
*/
export type Account = Prisma.AccountModel
export type Account = Prisma.AccountModel;
/**
* Model UserRole
*
*/
export type UserRole = Prisma.UserRoleModel
export type UserRole = Prisma.UserRoleModel;
/**
* Model Verification
*
*/
export type Verification = Prisma.VerificationModel
export type Verification = Prisma.VerificationModel;
/**
* Model Proclamation
*
*/
export type Proclamation = Prisma.ProclamationModel
export type Proclamation = Prisma.ProclamationModel;
/**
* Model UserProclamations
*
*/
export type UserProclamations = Prisma.UserProclamationsModel
export type UserProclamations = Prisma.UserProclamationsModel;
/**
* Model SolfeggioFrequency
*
*/
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel;
/**
* Model ProclamationCategory
*
*/
export type ProclamationCategory = Prisma.ProclamationCategoryModel
export type ProclamationCategory = Prisma.ProclamationCategoryModel;
/**
* Model ProclamationCategorySubscriptionLevel
*
*/
export type ProclamationCategorySubscriptionLevel =
Prisma.ProclamationCategorySubscriptionLevelModel;
/**
* Model LicenseKey
*
*/
export type LicenseKey = Prisma.LicenseKeyModel
export type LicenseKey = Prisma.LicenseKeyModel;
/**
* Model ApiKey
*
*/
export type ApiKey = Prisma.ApiKeyModel;
/**
* Model SubscriptionLevel
*
*/
export type SubscriptionLevel = Prisma.SubscriptionLevelModel
export type SubscriptionLevel = Prisma.SubscriptionLevelModel;
+44 -28
View File
@@ -1,4 +1,3 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
@@ -10,97 +9,114 @@
* 🟢 You can import this file directly.
*/
import * as process from 'node:process'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
import * as process from 'node:process';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url));
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.ts"
import * as $Class from "./internal/class.ts"
import * as Prisma from "./internal/prismaNamespace.ts"
import * as runtime from '@prisma/client/runtime/client';
import * as $Enums from './enums.ts';
import * as $Class from './internal/class.ts';
import * as Prisma from './internal/prismaNamespace.ts';
export * as $Enums from './enums.ts'
export * from "./enums.ts"
export * as $Enums from './enums.ts';
export * from './enums.ts';
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient()
* const prisma = new PrismaClient({
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
* })
* // Fetch zero or more Users
* const users = await prisma.user.findMany()
* ```
*
* Read more in our [docs](https://pris.ly/d/client).
*/
export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
export const PrismaClient = $Class.getPrismaClientClass();
export type PrismaClient<
LogOpts extends Prisma.LogLevel = never,
OmitOpts extends Prisma.PrismaClientOptions['omit'] = Prisma.PrismaClientOptions['omit'],
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>;
export { Prisma };
/**
* Model User
*
*/
export type User = Prisma.UserModel
export type User = Prisma.UserModel;
/**
* Model Profile
*
*/
export type Profile = Prisma.ProfileModel
export type Profile = Prisma.ProfileModel;
/**
* Model Address
*
*/
export type Address = Prisma.AddressModel
export type Address = Prisma.AddressModel;
/**
* Model Session
*
*/
export type Session = Prisma.SessionModel
export type Session = Prisma.SessionModel;
/**
* Model Account
*
*/
export type Account = Prisma.AccountModel
export type Account = Prisma.AccountModel;
/**
* Model UserRole
*
*/
export type UserRole = Prisma.UserRoleModel
export type UserRole = Prisma.UserRoleModel;
/**
* Model Verification
*
*/
export type Verification = Prisma.VerificationModel
export type Verification = Prisma.VerificationModel;
/**
* Model Proclamation
*
*/
export type Proclamation = Prisma.ProclamationModel
export type Proclamation = Prisma.ProclamationModel;
/**
* Model UserProclamations
*
*/
export type UserProclamations = Prisma.UserProclamationsModel
export type UserProclamations = Prisma.UserProclamationsModel;
/**
* Model SolfeggioFrequency
*
*/
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel;
/**
* Model ProclamationCategory
*
*/
export type ProclamationCategory = Prisma.ProclamationCategoryModel
export type ProclamationCategory = Prisma.ProclamationCategoryModel;
/**
* Model ProclamationCategorySubscriptionLevel
*
*/
export type ProclamationCategorySubscriptionLevel =
Prisma.ProclamationCategorySubscriptionLevelModel;
/**
* Model LicenseKey
*
*/
export type LicenseKey = Prisma.LicenseKeyModel
export type LicenseKey = Prisma.LicenseKeyModel;
/**
* Model ApiKey
*
*/
export type ApiKey = Prisma.ApiKeyModel;
/**
* Model SubscriptionLevel
*
*/
export type SubscriptionLevel = Prisma.SubscriptionLevelModel
export type SubscriptionLevel = Prisma.SubscriptionLevelModel;
+330 -377
View File
@@ -1,4 +1,3 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
@@ -9,441 +8,395 @@
* 🟢 You can import this file directly.
*/
import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.ts"
import type * as Prisma from "./internal/prismaNamespace.ts"
import type * as runtime from '@prisma/client/runtime/client';
import * as $Enums from './enums.ts';
import type * as Prisma from './internal/prismaNamespace.ts';
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
mode?: Prisma.QueryMode;
not?: Prisma.NestedStringFilter<$PrismaModel> | string;
};
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean;
};
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
mode?: Prisma.QueryMode;
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null;
};
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string;
};
export type IntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null;
};
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
sort: Prisma.SortOrder;
nulls?: Prisma.NullsOrder;
};
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
mode?: Prisma.QueryMode;
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedStringFilter<$PrismaModel>;
_max?: Prisma.NestedStringFilter<$PrismaModel>;
};
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedBoolFilter<$PrismaModel>;
_max?: Prisma.NestedBoolFilter<$PrismaModel>;
};
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
mode?: Prisma.QueryMode;
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null;
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>;
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>;
};
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>;
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>;
};
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null;
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>;
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>;
};
export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null;
};
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null;
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
};
export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntFilter<$PrismaModel> | number;
};
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type FloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_avg?: Prisma.NestedFloatFilter<$PrismaModel>;
_sum?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedIntFilter<$PrismaModel>;
_max?: Prisma.NestedIntFilter<$PrismaModel>;
};
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
not?: Prisma.NestedStringFilter<$PrismaModel> | string;
};
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean;
};
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null;
};
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string;
};
export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null;
};
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedStringFilter<$PrismaModel>;
_max?: Prisma.NestedStringFilter<$PrismaModel>;
};
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntFilter<$PrismaModel> | number;
};
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedBoolFilter<$PrismaModel>;
_max?: Prisma.NestedBoolFilter<$PrismaModel>;
};
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null;
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>;
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>;
};
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>;
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>;
};
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null;
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>;
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>;
};
export type NestedFloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null;
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null;
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null;
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null;
};
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null;
};
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null;
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
};
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_avg?: Prisma.NestedFloatFilter<$PrismaModel>;
_sum?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedIntFilter<$PrismaModel>;
_max?: Prisma.NestedIntFilter<$PrismaModel>;
};
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>;
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>;
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
not?: Prisma.NestedFloatFilter<$PrismaModel> | number;
};
+1 -4
View File
@@ -1,4 +1,3 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
@@ -9,7 +8,5 @@
* 🟢 You can import this file directly.
*/
// This file is empty because there are no enums in the schema.
export {}
export {};
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,3 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
@@ -15,40 +14,38 @@
* model files in the `model` directory!
*/
import * as runtime from "@prisma/client/runtime/index-browser"
import * as runtime from '@prisma/client/runtime/index-browser';
export type * from '../models.ts'
export type * from './prismaNamespace.ts'
export const Decimal = runtime.Decimal
export type * from '../models.ts';
export type * from './prismaNamespace.ts';
export const Decimal = runtime.Decimal;
export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}
DbNull: runtime.NullTypes.DbNull as new (secret: never) => typeof runtime.DbNull,
JsonNull: runtime.NullTypes.JsonNull as new (secret: never) => typeof runtime.JsonNull,
AnyNull: runtime.NullTypes.AnyNull as new (secret: never) => typeof runtime.AnyNull
};
/**
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const DbNull = runtime.DbNull
export const DbNull = runtime.DbNull;
/**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const JsonNull = runtime.JsonNull
export const JsonNull = runtime.JsonNull;
/**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const AnyNull = runtime.AnyNull
export const AnyNull = runtime.AnyNull;
export const ModelName = {
User: 'User',
@@ -62,25 +59,27 @@ export const ModelName = {
UserProclamations: 'UserProclamations',
SolfeggioFrequency: 'SolfeggioFrequency',
ProclamationCategory: 'ProclamationCategory',
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey',
SubscriptionLevel: 'SubscriptionLevel'
} as const
} as const;
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
export type ModelName = (typeof ModelName)[keyof typeof ModelName];
/*
* Enums
*/
export const TransactionIsolationLevel = {
export const TransactionIsolationLevel = runtime.makeStrictEnum({
ReadUncommitted: 'ReadUncommitted',
ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
} as const
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
} as const);
export type TransactionIsolationLevel =
(typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel];
export const UserScalarFieldEnum = {
id: 'id',
@@ -91,21 +90,21 @@ export const UserScalarFieldEnum = {
createdAt: 'createdAt',
updatedAt: 'updatedAt',
roleId: 'roleId'
} as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
} as const;
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum];
export const ProfileScalarFieldEnum = {
id: 'id',
userId: 'userId',
firstName: 'firstName',
lastName: 'lastName',
phoneNumber: 'phoneNumber'
} as const
export type ProfileScalarFieldEnum = (typeof ProfileScalarFieldEnum)[keyof typeof ProfileScalarFieldEnum]
phoneNumber: 'phoneNumber',
ghlContactId: 'ghlContactId'
} as const;
export type ProfileScalarFieldEnum =
(typeof ProfileScalarFieldEnum)[keyof typeof ProfileScalarFieldEnum];
export const AddressScalarFieldEnum = {
id: 'id',
@@ -114,11 +113,15 @@ export const AddressScalarFieldEnum = {
city: 'city',
state: 'state',
postalCode: 'postalCode',
country: 'country'
} as const
export type AddressScalarFieldEnum = (typeof AddressScalarFieldEnum)[keyof typeof AddressScalarFieldEnum]
country: 'country',
type: 'type',
isDefaultMailing: 'isDefaultMailing',
isDefaultBilling: 'isDefaultBilling',
isDefaultShipping: 'isDefaultShipping'
} as const;
export type AddressScalarFieldEnum =
(typeof AddressScalarFieldEnum)[keyof typeof AddressScalarFieldEnum];
export const SessionScalarFieldEnum = {
id: 'id',
@@ -129,10 +132,10 @@ export const SessionScalarFieldEnum = {
ipAddress: 'ipAddress',
userAgent: 'userAgent',
userId: 'userId'
} as const
export type SessionScalarFieldEnum = (typeof SessionScalarFieldEnum)[keyof typeof SessionScalarFieldEnum]
} as const;
export type SessionScalarFieldEnum =
(typeof SessionScalarFieldEnum)[keyof typeof SessionScalarFieldEnum];
export const AccountScalarFieldEnum = {
id: 'id',
@@ -148,18 +151,18 @@ export const AccountScalarFieldEnum = {
password: 'password',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type AccountScalarFieldEnum = (typeof AccountScalarFieldEnum)[keyof typeof AccountScalarFieldEnum]
} as const;
export type AccountScalarFieldEnum =
(typeof AccountScalarFieldEnum)[keyof typeof AccountScalarFieldEnum];
export const UserRoleScalarFieldEnum = {
id: 'id',
name: 'name'
} as const
export type UserRoleScalarFieldEnum = (typeof UserRoleScalarFieldEnum)[keyof typeof UserRoleScalarFieldEnum]
} as const;
export type UserRoleScalarFieldEnum =
(typeof UserRoleScalarFieldEnum)[keyof typeof UserRoleScalarFieldEnum];
export const VerificationScalarFieldEnum = {
id: 'id',
@@ -168,10 +171,10 @@ export const VerificationScalarFieldEnum = {
expiresAt: 'expiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type VerificationScalarFieldEnum = (typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum]
} as const;
export type VerificationScalarFieldEnum =
(typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum];
export const ProclamationScalarFieldEnum = {
id: 'id',
@@ -179,40 +182,48 @@ export const ProclamationScalarFieldEnum = {
content: 'content',
solfeggioFrequencyId: 'solfeggioFrequencyId',
categoryId: 'categoryId',
price: 'price',
subscriptionLevelId: 'subscriptionLevelId'
} as const
export type ProclamationScalarFieldEnum = (typeof ProclamationScalarFieldEnum)[keyof typeof ProclamationScalarFieldEnum]
} as const;
export type ProclamationScalarFieldEnum =
(typeof ProclamationScalarFieldEnum)[keyof typeof ProclamationScalarFieldEnum];
export const UserProclamationsScalarFieldEnum = {
userId: 'userId',
proclamationId: 'proclamationId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type UserProclamationsScalarFieldEnum = (typeof UserProclamationsScalarFieldEnum)[keyof typeof UserProclamationsScalarFieldEnum]
} as const;
export type UserProclamationsScalarFieldEnum =
(typeof UserProclamationsScalarFieldEnum)[keyof typeof UserProclamationsScalarFieldEnum];
export const SolfeggioFrequencyScalarFieldEnum = {
id: 'id',
name: 'name',
value: 'value'
} as const
export type SolfeggioFrequencyScalarFieldEnum = (typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum]
} as const;
export type SolfeggioFrequencyScalarFieldEnum =
(typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum];
export const ProclamationCategoryScalarFieldEnum = {
id: 'id',
name: 'name',
sortOrder: 'sortOrder',
parentId: 'parentId'
} as const
} as const;
export type ProclamationCategoryScalarFieldEnum = (typeof ProclamationCategoryScalarFieldEnum)[keyof typeof ProclamationCategoryScalarFieldEnum]
export type ProclamationCategoryScalarFieldEnum =
(typeof ProclamationCategoryScalarFieldEnum)[keyof typeof ProclamationCategoryScalarFieldEnum];
export const ProclamationCategorySubscriptionLevelScalarFieldEnum = {
categoryId: 'categoryId',
subscriptionLevelId: 'subscriptionLevelId'
} as const;
export type ProclamationCategorySubscriptionLevelScalarFieldEnum =
(typeof ProclamationCategorySubscriptionLevelScalarFieldEnum)[keyof typeof ProclamationCategorySubscriptionLevelScalarFieldEnum];
export const LicenseKeyScalarFieldEnum = {
id: 'id',
@@ -220,40 +231,49 @@ export const LicenseKeyScalarFieldEnum = {
active: 'active',
level: 'level',
userId: 'userId'
} as const
} as const;
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
export type LicenseKeyScalarFieldEnum =
(typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum];
export const ApiKeyScalarFieldEnum = {
id: 'id',
keyPrefix: 'keyPrefix',
keyHash: 'keyHash',
createdAt: 'createdAt',
expiresAt: 'expiresAt',
revokedAt: 'revokedAt'
} as const;
export type ApiKeyScalarFieldEnum =
(typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum];
export const SubscriptionLevelScalarFieldEnum = {
id: 'id',
name: 'name',
description: 'description'
} as const
export type SubscriptionLevelScalarFieldEnum = (typeof SubscriptionLevelScalarFieldEnum)[keyof typeof SubscriptionLevelScalarFieldEnum]
} as const;
export type SubscriptionLevelScalarFieldEnum =
(typeof SubscriptionLevelScalarFieldEnum)[keyof typeof SubscriptionLevelScalarFieldEnum];
export const SortOrder = {
asc: 'asc',
desc: 'desc'
} as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
} as const;
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder];
export const QueryMode = {
default: 'default',
insensitive: 'insensitive'
} as const
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
} as const;
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode];
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
} as const;
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder];
+16 -15
View File
@@ -1,4 +1,3 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
@@ -8,17 +7,19 @@
*
* 🟢 You can import this file directly.
*/
export type * from './models/User.ts'
export type * from './models/Profile.ts'
export type * from './models/Address.ts'
export type * from './models/Session.ts'
export type * from './models/Account.ts'
export type * from './models/UserRole.ts'
export type * from './models/Verification.ts'
export type * from './models/Proclamation.ts'
export type * from './models/UserProclamations.ts'
export type * from './models/SolfeggioFrequency.ts'
export type * from './models/ProclamationCategory.ts'
export type * from './models/LicenseKey.ts'
export type * from './models/SubscriptionLevel.ts'
export type * from './commonInputTypes.ts'
export type * from './models/User.ts';
export type * from './models/Profile.ts';
export type * from './models/Address.ts';
export type * from './models/Session.ts';
export type * from './models/Account.ts';
export type * from './models/UserRole.ts';
export type * from './models/Verification.ts';
export type * from './models/Proclamation.ts';
export type * from './models/UserProclamations.ts';
export type * from './models/SolfeggioFrequency.ts';
export type * from './models/ProclamationCategory.ts';
export type * from './models/ProclamationCategorySubscriptionLevel.ts';
export type * from './models/LicenseKey.ts';
export type * from './models/ApiKey.ts';
export type * from './models/SubscriptionLevel.ts';
export type * from './commonInputTypes.ts';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -9
View File
@@ -10,15 +10,15 @@
// ✔ Delete a card
// ✔ Issue a refund
import type { Decimal } from "@prisma/client/runtime/client";
import type { Decimal } from '@prisma/client/runtime/client';
export interface CreditDebitCard {
first_name: string,
last_name: string,
card_number: number,
expiration_month: number,
expiration_year: number,
security_code: number,
first_name: string;
last_name: string;
card_number: number;
expiration_month: number;
expiration_year: number;
security_code: number;
}
export async function chargeCreditDebitCard(card: CreditDebitCard, amount: Decimal): Promise<null> {
@@ -27,11 +27,15 @@ export async function chargeCreditDebitCard (card: CreditDebitCard, amount: Deci
* for the amount specified.
*/
console.log(`charged card ${card} for $${amount}`);
await new Promise(resolve => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000));
return null;
}
export async function chargedStoredCard(cardNumber: number, profileID: string, amount: Decimal): Promise<null> {
export async function chargedStoredCard(
cardNumber: number,
profileID: string,
amount: Decimal
): Promise<null> {
/**
* chargedStoredCard attempts to request payment from a valid card,
* for the amount specified.
+81
View File
@@ -0,0 +1,81 @@
import { randomBytes, createHash } from 'node:crypto';
import { prisma } from '$lib/db';
export type ApiKeyRecord = {
id: string;
keyPrefix: string;
createdAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
};
export type GeneratedKey = {
plaintext: string;
prefix: string;
hash: string;
};
export function generateApiKey(): GeneratedKey {
const raw = randomBytes(32);
const hex = raw.toString('hex');
const plaintext = `sk_${hex}`;
const prefix = plaintext.slice(0, 11);
const hash = createHash('sha256').update(plaintext).digest('hex');
return { plaintext, prefix, hash };
}
export async function validateApiKey(key: string): Promise<ApiKeyRecord | null> {
const hash = createHash('sha256').update(key).digest('hex');
const record = await prisma.apiKey.findUnique({ where: { keyHash: hash } });
if (!record) return null;
if (record.revokedAt && record.revokedAt <= new Date()) return null;
if (record.expiresAt && record.expiresAt <= new Date()) return null;
return record;
}
export async function rotateApiKey(emergency = false): Promise<{
plaintext: string;
prefix: string;
previousExpiresAt: Date | null;
previousRevokedAt: Date | null;
}> {
const now = new Date();
const activeKeys = await prisma.apiKey.findMany({
where: {
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }]
}
});
let previousExpiresAt: Date | null = null;
let previousRevokedAt: Date | null = null;
if (emergency) {
previousRevokedAt = now;
for (const key of activeKeys) {
await prisma.apiKey.update({
where: { id: key.id },
data: { revokedAt: now }
});
}
previousExpiresAt = null;
} else {
const graceEnd = new Date(now.getTime() + 48 * 60 * 60 * 1000);
previousExpiresAt = graceEnd;
for (const key of activeKeys) {
await prisma.apiKey.update({
where: { id: key.id },
data: { expiresAt: graceEnd }
});
}
previousRevokedAt = null;
}
const { plaintext, prefix, hash } = generateApiKey();
await prisma.apiKey.create({
data: { keyPrefix: prefix, keyHash: hash }
});
return { plaintext, prefix, previousExpiresAt, previousRevokedAt };
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { sendMail } = vi.hoisted(() => ({
sendMail: vi.fn()
}));
vi.mock('$env/static/private', () => ({
BETTER_AUTH_URL: 'http://localhost:5173'
}));
vi.mock('./resend', () => ({
sendMail
}));
import { sendVerificationEmail } from './email-verification';
describe('sendVerificationEmail', () => {
beforeEach(() => {
sendMail.mockReset();
});
it('calls sendMail with correct email and template', async () => {
sendMail.mockResolvedValue(undefined);
await sendVerificationEmail({
email: 'jane@example.com',
token: 'abc123'
});
expect(sendMail).toHaveBeenCalledWith({
to: 'jane@example.com',
subject: 'Verify your email address',
templateId: 'email-verification-email',
data: {
target_email_address: 'jane@example.com',
verification_link:
'http://localhost:5173/api/auth/verify-email?token=abc123&callbackURL=/login?verified=true'
}
});
});
it('constructs verification URL with the given token', async () => {
sendMail.mockResolvedValue(undefined);
await sendVerificationEmail({
email: 'test@example.com',
token: 'xyz789'
});
const call = sendMail.mock.calls[0][0];
expect(call.data.verification_link).toContain('xyz789');
expect(call.data.verification_link).toContain('/login?verified=true');
});
it('throws when sendMail throws', async () => {
sendMail.mockRejectedValue(new Error('Resend error'));
await expect(
sendVerificationEmail({
email: 'jane@example.com',
token: 'abc123'
})
).rejects.toThrow('Resend error');
});
});
+16
View File
@@ -0,0 +1,16 @@
import { BETTER_AUTH_URL } from '$env/static/private';
import { sendMail } from './resend';
export async function sendVerificationEmail(options: { email: string; token: string }) {
const verificationUrl = `${BETTER_AUTH_URL}/api/auth/verify-email?token=${options.token}&callbackURL=/login?verified=true`;
await sendMail({
to: options.email,
subject: 'Verify your email address',
templateId: 'email-verification-email',
data: {
target_email_address: options.email,
verification_link: verificationUrl
}
});
}
+56
View File
@@ -0,0 +1,56 @@
/** src/lib/server/resend.ts
* AUTHOR: Neil Clack
* EMAIL: nclack@radiantwavetech.com
* DATE: 07/06/2027
*
* A server side library and client for Resend
* Includes the Resend client, as well as functions
* for different email scenarios.
*
*/
import { Resend } from 'resend';
import { RESEND_API_KEY, EMAIL_FROM, RESEND_TEMPLATE_ID } from '$env/static/private';
export const resend = new Resend(RESEND_API_KEY);
/**
* Send a transactional email using a Resend-hosted template.
*
* @example
* await sendMail({
* to: 'jane@example.com',
* templateId: 'tmpl_abc123', // optional — defaults to RESEND_TEMPLATE_ID
* subject: 'Your license key is ready', // optional — defaults to 'Template Email'
* data: { // optional — template variables
* firstName: 'Jane',
* licenseKey: 'RWT-7X9K-2M4Q'
* }
* });
*
* @returns The Resend API response data on success.
* @throws {Error} If the Resend API call fails.
*/
export async function sendMail(options: {
to: string;
subject?: string;
templateId?: string;
data?: Record<string, string | number>;
}) {
const { to, subject, templateId, data } = options;
const result = await resend.emails.send({
from: EMAIL_FROM,
to: [to],
subject: subject ?? 'Template Email',
template: {
id: templateId ?? RESEND_TEMPLATE_ID,
variables: data ?? {}
}
});
if (result.error) {
throw new Error(result.error.message);
}
return result.data;
}
+98 -77
View File
@@ -1,8 +1,8 @@
# svelte-admin
A config-driven admin panel framework for SvelteKit + Prisma. Define your models once in a
registry and get full CRUD UI with dynamic routing, role-based access control, searchable
dropdowns, and related-record panels — no per-model route files needed.
registry and get full CRUD UI with per-model static pages, role-based access control, searchable
tables, FK dropdowns, TipTap editor for rich text, and related-record panels.
## Architecture
@@ -13,27 +13,30 @@ src/lib/svelte-admin/ # Framework core
├── roles.ts # Role hierarchy and access helpers
├── prisma-delegate.ts # Dynamic Prisma delegate lookup
├── introspect.ts # Field label generation and dot-notation resolution
├── form-data.ts # FormData → typed object parser
├── form-data.ts # FormData -> typed object parser
├── crud.ts # CRUD action factory (load, upsert, delete)
└── index.ts # Barrel exports
src/routes/(protected)/admin/ # Dynamic admin routes
src/routes/(protected)/admin/ # Per-model admin pages (no dynamic [model] routing)
├── +layout.server.ts # Role-based access guard
├── +layout.svelte # Layout shell
├── +page.server.ts # Admin home (model grid)
├── +page.svelte # Admin home UI
├── [model]/
│ ├── +page.server.ts # List view + create action
│ ├── +page.svelte # List view UI
│ └── [id]/
│ ├── +page.server.ts # Edit view + update/delete actions
│ └── +page.svelte # Edit view UI
├── users/ # Each model gets its own page directory
├── profiles/
├── addresses/
├── user-roles/
├── proclamations/
├── solfeggio-frequencies/
├── proclamation-categories/
├── subscription-levels/
└── license-keys/ # listOnly — no create/edit modal
src/lib/components/admin/ # Reusable admin UI components
├── AdminTable.svelte # Sortable, searchable data table
├── AdminForm.svelte # Dynamic form with type-aware inputs
├── ComboBox.svelte # Searchable dropdown for FK fields
├── RelatedHasOne.svelte # Display panel for 1:1 related records
└── RelatedHasMany.svelte # Display table for 1:N related records
├── AdminModal.svelte # Modal overlay for CRUD forms
├── TipTap.svelte # Rich-text editor (in components/tiptap/)
```
## Adding a New Model
@@ -74,10 +77,11 @@ src/lib/components/admin/ # Reusable admin UI components
}
```
3. **Done.** Visit `/admin/widgets`. The dynamic `[model]` routes handle list, create, edit,
and delete automatically.
3. **Create the page directory** `src/routes/(protected)/admin/widgets/` with:
- `+page.server.ts` — load records + form actions using `crud.ts` helpers
- `+page.svelte` — render AdminTable + AdminModal with AdminForm
No new route files, no new components. The registry is the only thing you touch.
4. **Done.** Visit `/admin/widgets`. The page handles list, create, edit via modal.
## Configuration Reference
@@ -95,25 +99,26 @@ No new route files, no new components. The registry is the only thing you touch.
| `searchFields` | `string[]` | Yes | Fields searched by the client-side search filter. |
| `formFields` | `string[]` | Yes | Fields rendered in create/edit forms, in display order. |
| `readonlyFields` | `string[]` | No | Fields shown as read-only on the edit page. |
| `listOnly` | `boolean` | No | If `true`, suppresses edit links in the table. |
| `listOnly` | `boolean` | No | If `true`, suppresses edit links in the table and no modal. |
| `fieldTypes` | `Record<string, PrismaScalarType>` | Yes | Maps field names to scalar types. Controls input rendering and FormData parsing. |
| `relationOptions` | `Record<string, RelationOptionConfig>` | No | FK fields that render as ComboBox dropdowns. |
| `relatedRecords` | `RelatedRecordConfig[]` | No | Has-one/has-many panels shown on edit pages. |
| `relationOptions` | `Record<string, RelationOptionConfig>` | No | FK fields that render as `<select>` dropdowns. |
| `relatedRecords` | `RelatedRecordConfig[]` | No | Has-one/has-many panels shown on edit pages (display only via related records table). |
| `accessFilter` | `(viewerRoleId: number) => where \| undefined` | No | Returns a Prisma `where` clause to filter records by viewer role. `undefined` = no filter. |
### PrismaScalarType
`'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime'`
`'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Tiptap'`
Controls both form input rendering and FormData parsing:
| Type | HTML Input | Parse Logic |
| ---------- | ----------------------------------- | ------------------------------ |
| ---------- | -------------------------------------- | ------------------------------- |
| `String` | `<input type="text">` | `raw.toString()` |
| `Int` | `<input type="number" step="1">` | `parseInt()`, empty → `null` |
| `Float` | `<input type="number" step="0.01">` | `parseFloat()`, empty → `null` |
| `Boolean` | `<input type="checkbox">` | `'on'` or `'true'` → `true` |
| `DateTime` | `<input type="datetime-local">` | `new Date()`, empty → `null` |
| `Int` | `<input type="number" step="1">` | `parseInt()`, empty -> `null` |
| `Float` | `<input type="number" step="0.01">` | `parseFloat()`, empty -> `null` |
| `Boolean` | `<input type="checkbox">` | `'on'` or `'true'` -> `true` |
| `DateTime` | `<input type="datetime-local">` | `new Date()`, empty -> `null` |
| `Tiptap` | TipTap rich-text editor (hidden input) | `raw.toString()` |
### RelationOptionConfig
@@ -142,14 +147,18 @@ Controls both form input rendering and FormData parsing:
The `fieldTypes` mapping is the central mechanism connecting Prisma scalar types to HTML inputs
and form data parsing. Every field in `formFields` must have a corresponding entry in `fieldTypes`.
When a field also has a `relationOptions` entry, the framework renders a `ComboBox` (searchable
dropdown) instead of the default input for that type.
When a field also has a `relationOptions` entry, the framework renders a `<select>` dropdown
instead of the default input for that type. The options are fetched server-side by `crud.ts`
and passed to the client as `relationOptionsData`.
For `Tiptap` fields, the TipTap rich-text editor is rendered. The editor content is stored in a
hidden input and submitted with the form.
## Relations
### Foreign Key Dropdowns (relationOptions)
When a form field is a foreign key, add a `relationOptions` entry to render a searchable ComboBox:
When a form field is a foreign key, add a `relationOptions` entry to render a `<select>` dropdown:
```ts
{
@@ -165,7 +174,8 @@ When a form field is a foreign key, add a `relationOptions` entry to render a se
}
```
The route loader automatically queries the related model and passes options to the ComboBox.
The `crud.ts` `loadRecords` function automatically queries the related model and passes options
to the page as `relationOptionsData`.
### Eager Loading (include)
@@ -200,9 +210,7 @@ relatedRecords: [
];
```
The `relationKey` must match a key in `include` so the data is actually loaded. `has-one` renders
a key-value panel via `RelatedHasOne`; `has-many` renders a table via `RelatedHasMany`. Both
support an optional `editSlug` to link to the related record's edit page.
The `relationKey` must match a key in `include` so the data is actually loaded.
## Access Control
@@ -228,38 +236,40 @@ Use `accessFilter` to restrict which records a viewer can see:
```ts
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= ROLES.SUPER) return undefined; // no filter
if (viewerRoleId <= ROLES.SUPER) return undefined;
if (viewerRoleId <= ROLES.ADMIN) {
return { roleId: { gte: ROLES.STAFF } }; // can't see other admins
return { roleId: { gte: ROLES.STAFF } };
}
return { roleId: { gt: ROLES.STAFF } }; // staff sees only regular users
return { roleId: { gt: ROLES.STAFF } };
};
```
## How Dynamic Routing Works
## How Static Pages Work
1. User visits `/admin/proclamations` (or `/admin/proclamations/abc123`)
2. SvelteKit matches the `[model]` (and `[id]`) route params
3. `+page.server.ts` calls `getModelConfig(params.model)` to look up the config
4. `getDelegate(config)` returns `prisma[lcfirst(config.modelName)]` (e.g., `prisma.proclamation`)
5. The delegate runs `findMany` (list), `findUnique` (edit), `create`, `update`, or `delete`
6. Data + config are passed to the Svelte page component
7. Components (`AdminTable`, `AdminForm`, etc.) render based on config
1. User visits `/admin/proclamations`
2. SvelteKit matches the static route `/admin/proclamations/+page.server.ts`
3. `+page.server.ts` calls `getModelConfig('proclamations')` to look up the config
4. `loadRecords(config)` calls `prisma.proclamation.findMany()` with the config's `include` and `accessFilter`
5. For each FK field with a `relationOptions`, it also queries the related model for dropdown options
6. Data + config are passed to `+page.svelte`
7. The page renders `AdminTable` with records and `AdminModal` + `AdminForm` for create/edit
## Component API
### AdminTable
| Prop | Type | Description |
| -------------- | --------------------------- | ------------------------------------ |
| -------------- | ---------------------------------- | ------------------------------------ |
| `records` | `Record<string, unknown>[]` | Data rows |
| `listFields` | `string[]` | Column keys (dot-notation supported) |
| `searchFields` | `string[]` | Fields for client-side search |
| `modelSlug` | `string` | Used for building edit links |
| `listOnly` | `boolean?` | Suppress edit links |
| `fieldTypes` | `Record<string, PrismaScalarType>` | Type mapping for cell formatting |
| `singular` | `string` | Singular label for UI text |
| `onRowClick` | `(record) => void?` | Click handler for edit modal |
| `onNew` | `() => void?` | Click handler for create modal |
Features: client-side search, click-to-sort columns, auto-formatted cells (dates, booleans,
decimals), filtered count display.
decimals), empty state.
### AdminForm
@@ -270,46 +280,31 @@ decimals), filtered count display.
| `relationOptions` | `Record<string, RelationOptionConfig>?` | FK dropdown configs |
| `relationOptionsData` | `Record<string, { label; value }[]>?` | Loaded dropdown options |
| `record` | `Record<string, unknown>?` | Pre-fill values (edit mode) |
| `form` | `ActionData?` | Carries validation errors |
| `action` | `string` | Form action (e.g., `'?/create'`) |
| `readonlyFields` | `string[]?` | Read-only field names |
| `action` | `string` | Form action (e.g., `'?/upsert'`) |
| `submitLabel` | `string?` | Button text |
### ComboBox
### AdminModal
| Prop | Type | Description |
| ------------- | -------------------- | ------------------ |
| `id` | `string` | Element ID |
| `name` | `string` | Form field name |
| `options` | `{ label; value }[]` | Dropdown items |
| `placeholder` | `string?` | Placeholder text |
| `value` | `string \| number?` | Pre-selected value |
| ---------- | ------------ | ------------------ |
| `open` | `boolean` | Show/hide modal |
| `title` | `string` | Modal heading |
| `onClose` | `() => void` | Close handler |
| `children` | `Snippet` | Modal body content |
Features: type-to-search filtering, keyboard navigation (ArrowUp/Down, Enter, Escape),
clear button, scroll-to-highlight.
### RelatedHasOne
### TipTap
| Prop | Type | Description |
| --------------- | --------------------------------- | ------------------------------------ |
| `label` | `string` | Section heading |
| `record` | `Record<string, unknown> \| null` | Related record |
| `displayFields` | `string[]` | Fields to display as key-value pairs |
| `editSlug` | `string?` | Links to edit the related record |
### RelatedHasMany
| Prop | Type | Description |
| --------------- | --------------------------- | ----------------------------- |
| `label` | `string` | Section heading (shows count) |
| `records` | `Record<string, unknown>[]` | Related records |
| `displayFields` | `string[]` | Table columns |
| `editSlug` | `string?` | Per-row edit links |
| ---------------- | -------- | --------------------------------- |
| `initialContent` | `string` | Initial HTML content |
| `name` | `string` | Hidden input name for form submit |
## Utility Functions
| Function | Module | Description |
| ------------------------------------------ | -------------------- | ----------------------------------------------------------------------------- |
| `fieldNameToLabel(name)` | `introspect.ts` | `'solfeggioFrequencyId'` → `'Solfeggio Frequency'` (strips `Id`, adds spaces) |
| ------------------------------------------ | -------------------- | ------------------------------------------------------------------------------ |
| `fieldNameToLabel(name)` | `introspect.ts` | `'solfeggioFrequencyId'` -> `'Solfeggio Frequency'` (strips `Id`, adds spaces) |
| `resolveFieldValue(record, path)` | `introspect.ts` | Resolves dot-notation paths like `'category.name'` on nested objects |
| `extractFormData(formData, fields, types)` | `form-data.ts` | Parses `FormData` with type-aware conversion per `PrismaScalarType` |
| `getDelegate(config)` | `prisma-delegate.ts` | Returns `prisma[lcfirst(modelName)]` delegate |
@@ -318,6 +313,32 @@ clear button, scroll-to-highlight.
| `getAllModelConfigs()` | `registry.ts` | Returns all registered model configs |
| `hasRole(roleId, required)` | `roles.ts` | `true` if `roleId <= required` |
| `canAccessAdmin(roleId)` | `roles.ts` | `true` if `roleId <= ROLES.STAFF` |
| `loadRecords(locals, config)` | `crud.ts` | Fetches records + FK dropdown options for a model |
| `upsertRecord(formData, config, locals)` | `crud.ts` | Creates or updates a record based on presence of `id` in form data |
| `deleteRecord(formData, config, locals)` | `crud.ts` | Deletes a record by ID |
## Models in Admin
| Registry Slug | Prisma Model | Notes |
| ------------------------- | -------------------- | ----------------------------------- |
| `users` | User | Role assignment, view-only fields |
| `profiles` | Profile | Linked to user FK |
| `addresses` | Address | Linked to profile FK |
| `user-roles` | UserRole | |
| `proclamations` | Proclamation | TipTap rich-text editor for content |
| `solfeggio-frequencies` | SolfeggioFrequency | |
| `proclamation-categories` | ProclamationCategory | Parent category hierarchy |
| `license-keys` | LicenseKey | listOnly — read-only table, no CRUD |
| `subscription-levels` | SubscriptionLevel | |
### Excluded from Admin
| Model | Reason |
| ----------------- | --------------------------------------------- |
| Session | Better Auth internal — not for manual editing |
| Account | Better Auth internal |
| Verification | Better Auth internal |
| UserProclamations | Composite key — managed via other models |
## Complete Example
@@ -364,7 +385,7 @@ A hypothetical `Product` model showing all configuration features:
],
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= 2) return undefined;
return { active: true }; // staff only sees active products
return { active: true };
}
}
```
+227
View File
@@ -0,0 +1,227 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLocals, mockUser } from '$lib/test-utils';
import { getModelConfig } from './registry';
// Ensure superValidate can be imported from server path in Node.js/SSR context
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
vi.mock('$lib/db', () => {
const mkdel = () => ({
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn()
});
return {
prisma: {
userRole: mkdel(),
proclamation: mkdel(),
proclamationCategory: mkdel(),
solfeggioFrequency: mkdel(),
subscriptionLevel: mkdel()
}
};
});
import { prisma } from '$lib/db';
import { loadRecords, upsertRecord, deleteRecord } from './crud';
const userRoleFindMany = prisma.userRole.findMany as ReturnType<typeof vi.fn>;
const create = prisma.userRole.create as ReturnType<typeof vi.fn>;
const update = prisma.userRole.update as ReturnType<typeof vi.fn>;
const deletes = prisma.userRole.delete as ReturnType<typeof vi.fn>;
const findUnique = prisma.userRole.findUnique as ReturnType<typeof vi.fn>;
const findFirst = prisma.userRole.findFirst as ReturnType<typeof vi.fn>;
const config = getModelConfig('user-roles')!;
describe('crud loadRecords', () => {
beforeEach(() => {
userRoleFindMany.mockReset();
});
it('returns records', async () => {
userRoleFindMany.mockResolvedValue([{ id: 1, name: 'Admin' }]);
const locals = createLocals(mockUser() as any);
const result = await loadRecords(locals, config);
expect(result.records).toEqual([{ id: 1, name: 'Admin' }]);
});
it('returns empty records array', async () => {
userRoleFindMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
const result = await loadRecords(locals, config);
expect(result.records).toEqual([]);
});
});
describe('crud upsertRecord', () => {
beforeEach(() => {
create.mockReset();
update.mockReset();
findUnique.mockReset();
findFirst.mockReset();
});
it('creates a new record when no id', async () => {
create.mockResolvedValue({ id: 2, name: 'Editor' });
const fd = new FormData();
fd.set('name', 'Editor');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(create).toHaveBeenCalledWith({ data: { name: 'Editor' } });
expect(result).toEqual({ success: true });
});
it('updates an existing record when id is present', async () => {
findUnique.mockResolvedValue({ id: 1, name: 'Old Name' });
const fd = new FormData();
fd.set('id', '1');
fd.set('name', 'New Name');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(update).toHaveBeenCalledWith({ where: { id: 1 }, data: { name: 'New Name' } });
expect(result).toEqual({ success: true });
});
it('returns error when updating non-existent record', async () => {
findUnique.mockResolvedValue(null);
const fd = new FormData();
fd.set('id', '999');
fd.set('name', 'Ghost');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(result).toEqual({ success: false, error: 'Record not found' });
});
});
describe('crud deleteRecord', () => {
beforeEach(() => {
deletes.mockReset();
});
it('deletes a record by id', async () => {
deletes.mockResolvedValue({ id: 1, name: 'Admin' });
const fd = new FormData();
fd.set('id', '1');
const locals = createLocals(mockUser() as any);
const result = await deleteRecord(fd, config, locals);
expect(deletes).toHaveBeenCalledWith({ where: { id: 1 } });
expect(result).toEqual({ success: true });
});
it('returns error when id is missing', async () => {
const fd = new FormData();
const locals = createLocals(mockUser() as any);
const result = await deleteRecord(fd, config, locals);
expect(result).toEqual({ success: false, error: 'Missing id' });
});
});
describe('P2002 unique constraint handling', () => {
beforeEach(() => {
create.mockReset();
});
it('returns error with field names when meta.target is populated', async () => {
const p2002 = new Error('Unique constraint failed');
(p2002 as any).code = 'P2002';
(p2002 as any).meta = { target: ['name'], modelName: 'UserRole' };
create.mockRejectedValue(p2002);
const fd = new FormData();
fd.set('name', 'Admin');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(result).toEqual({
success: false,
error: 'A record with this name already exists.',
fieldErrors: { name: 'Already taken' }
});
});
it('returns generic message when meta.target is empty', async () => {
const p2002 = new Error('Unique constraint failed');
(p2002 as any).code = 'P2002';
(p2002 as any).meta = { target: [], modelName: 'UserRole' };
create.mockRejectedValue(p2002);
const fd = new FormData();
fd.set('name', 'Admin');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(result).toEqual({
success: false,
error: 'A database constraint violation occurred. The record could not be created.'
});
});
it('returns generic message when meta.target is missing', async () => {
const p2002 = new Error('Unique constraint failed');
(p2002 as any).code = 'P2002';
(p2002 as any).meta = { modelName: 'UserRole' };
create.mockRejectedValue(p2002);
const fd = new FormData();
fd.set('name', 'Admin');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(result).toEqual({
success: false,
error: 'A database constraint violation occurred. The record could not be created.'
});
});
});
describe('sveltekit-superforms import compatibility', () => {
it('imports superValidate from server path without pulling in SuperDebug.svelte', async () => {
expect(superValidate).toBeDefined();
});
it('calls superValidate with a Request and validates against a Zod schema', async () => {
const { z } = await import('zod');
const schema = z.object({ name: z.string().min(1) });
const body = new URLSearchParams({ name: 'test' });
const request = new Request('http://localhost', {
method: 'POST',
body,
headers: { 'content-type': 'application/x-www-form-urlencoded' }
});
const form = await superValidate(request, zod4(schema));
expect(form.valid).toBe(true);
expect(form.data).toEqual({ name: 'test' });
});
});
describe('prisma client version consistency', () => {
it('generated client references existing query compiler files', async () => {
const fs = await import('fs');
const path = await import('path');
const runtimeDir = path.resolve('node_modules/@prisma/client/runtime');
const generatedFile = path.resolve('src/lib/generated/prisma/internal/class.ts');
const content = fs.readFileSync(generatedFile, 'utf-8');
const compilerMatch = content.match(/import\(["']@prisma\/client\/runtime\/([^"']+)["']\)/);
expect(compilerMatch).not.toBeNull();
const compilerPath = path.join(runtimeDir, compilerMatch![1]);
expect(fs.existsSync(compilerPath)).toBe(true);
});
});
+190
View File
@@ -0,0 +1,190 @@
import { getDelegate, parseId } from './prisma-delegate';
import { extractFormData } from './form-data';
import type { AdminModelConfig } from './types';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import * as Schemas from './schemas';
import type { z } from 'zod';
const modelSchemas: Record<string, z.ZodObject<any>> = {
User: Schemas.User,
Profile: Schemas.Profile,
UserRole: Schemas.UserRole,
Proclamation: Schemas.Proclamation,
ProclamationCategory: Schemas.ProclamationCategory,
SolfeggioFrequency: Schemas.SolfeggioFrequency,
SubscriptionLevel: Schemas.SubscriptionLevel,
LicenseKey: Schemas.LicenseKey
};
export function superFormErrorsToFieldErrors(superErrors: Record<string, unknown>): {
error: string;
fieldErrors: Record<string, string>;
} {
const fieldErrors: Record<string, string> = {};
let formError = '';
for (const [key, value] of Object.entries(superErrors)) {
if (key === '_errors' && Array.isArray(value)) {
formError = (value[0] as string) ?? '';
} else if (
value &&
typeof value === 'object' &&
'_errors' in value &&
Array.isArray((value as Record<string, unknown>)._errors)
) {
const errMsg = ((value as Record<string, unknown>)._errors as string[])[0];
fieldErrors[key] = errMsg;
}
}
return { error: formError, fieldErrors };
}
export async function loadRecords(locals: App.Locals, config: AdminModelConfig) {
const delegate = getDelegate(config);
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
const where = config.accessFilter?.(viewerRoleId);
const records = await delegate.findMany({
where,
include: config.include,
orderBy: { id: 'asc' }
});
const relationOptionsData: Record<string, { label: string; value: string | number }[]> = {};
for (const [field, relConfig] of Object.entries(config.relationOptions ?? {})) {
const relDelegate = getDelegate({ ...config, modelName: relConfig.model });
const selectFields: Record<string, boolean> = {
[relConfig.valueField]: true,
[relConfig.labelField]: true
};
const options = await relDelegate.findMany({
select: selectFields,
orderBy: { [relConfig.labelField]: 'asc' }
});
const mapped = (options as Record<string, unknown>[]).map((opt: Record<string, unknown>) => ({
label: String(opt[relConfig.labelField]),
value: opt[relConfig.valueField] as string | number
}));
relationOptionsData[field] = mapped;
}
return {
records: records as Record<string, unknown>[],
relationOptionsData
};
}
export async function upsertRecord(
requestOrFormData: Request | FormData,
config: AdminModelConfig,
locals: App.Locals
) {
const formData =
requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData();
const schema = modelSchemas[config.modelName];
const form = schema ? await superValidate(formData, zod4(schema)) : null;
if (form && !form.valid) {
const { error, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { ...form, success: false, error, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const data =
form?.data ??
(extractFormData(formData, config.formFields, config.fieldTypes) as Record<string, unknown>);
// Strip virtual/UI-only fields from Prisma payload
for (const f of config.dataOnlyFields ?? []) {
delete data[f];
}
const delegate = getDelegate(config);
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
const where = config.accessFilter?.(viewerRoleId);
if (rawId) {
const id = parseId(config, rawId);
const existing = await delegate.findUnique({
where: { id },
...((config.include ? { include: config.include } : {}) as object)
});
if (!existing) {
return { success: false, error: 'Record not found' };
}
if (where) {
const match = await delegate.findFirst({
where: { id, ...where }
});
if (!match) {
return { success: false, error: 'Access denied' };
}
}
await delegate.update({
where: { id },
data
});
return { success: true };
} else {
try {
await delegate.create({ data });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
const meta = (e as { meta?: { target?: string[]; modelName?: string } }).meta;
const fields = meta?.target ?? [];
console.error(
`[crud] P2002 on ${meta?.modelName ?? 'unknown'} create. data=`,
data,
`meta.target=`,
meta?.target
);
if (fields.length === 0) {
return {
success: false,
error: 'A database constraint violation occurred. The record could not be created.'
};
}
return {
success: false,
error: `A record with this ${fields.join(', ')} already exists.`,
fieldErrors: Object.fromEntries(fields.map((f) => [f, 'Already taken']))
};
}
throw e;
}
return { success: true };
}
}
export async function deleteRecord(
requestOrFormData: Request | FormData,
config: AdminModelConfig,
locals: App.Locals
) {
const delegate = getDelegate(config);
const formData =
requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
const id = parseId(config, rawId);
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
const where = config.accessFilter?.(viewerRoleId);
if (where) {
const match = await delegate.findFirst({ where: { id, ...where } });
if (!match) return { success: false, error: 'Access denied' };
}
await delegate.delete({ where: { id } });
return { success: true };
}
+4
View File
@@ -31,6 +31,10 @@ export function extractFormData(
data[field] = val === '' ? null : new Date(val);
break;
}
case 'Tiptap': {
data[field] = raw?.toString() ?? '';
break;
}
default: {
data[field] = raw?.toString() ?? '';
break;
+62 -12
View File
@@ -7,6 +7,7 @@ const models: AdminModelConfig[] = [
modelName: 'User',
displayName: 'Users',
displayNameSingular: 'User',
listOnly: true,
idType: 'String',
include: { role: true, profile: true, licenseKeys: true },
listFields: ['name', 'email', 'emailVerified', 'role.name', 'createdAt'],
@@ -72,7 +73,7 @@ const models: AdminModelConfig[] = [
relationKey: 'addresses',
label: 'Addresses',
type: 'has-many',
displayFields: ['street', 'city', 'state', 'zip']
displayFields: ['type', 'street', 'city', 'state', 'postalCode', 'country', 'isDefault']
}
]
},
@@ -96,17 +97,31 @@ const models: AdminModelConfig[] = [
displayName: 'Proclamations',
displayNameSingular: 'Proclamation',
idType: 'String',
include: { solfeggioFrequency: true, category: { include: { parent: true } } },
listFields: ['name', 'category.displayName', 'solfeggioFrequency.name', 'price'],
include: {
solfeggioFrequency: true,
category: { include: { parent: true } },
subscriptionLevel: true
},
listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'subscriptionLevel.name'],
searchFields: ['name', 'content'],
formFields: ['name', 'solfeggioFrequencyId', 'categoryId', 'price', 'content'],
formFields: [
'name',
'solfeggioFrequencyId',
'parentCategoryId',
'categoryId',
'subscriptionLevelId',
'content'
],
readonlyFields: ['id'],
dataOnlyFields: ['parentCategoryId'],
cascadeFields: { categoryId: { parentField: 'parentCategoryId' } },
fieldTypes: {
name: 'String',
content: 'Tiptap',
solfeggioFrequencyId: 'Int',
parentCategoryId: 'Int',
categoryId: 'Int',
price: 'Float'
subscriptionLevelId: 'Int'
},
relationOptions: {
solfeggioFrequencyId: {
@@ -114,10 +129,20 @@ const models: AdminModelConfig[] = [
labelField: 'name',
valueField: 'id'
},
parentCategoryId: {
model: 'ProclamationCategory',
labelField: 'name',
valueField: 'id'
},
categoryId: {
model: 'ProclamationCategory',
labelField: 'name',
valueField: 'id'
},
subscriptionLevelId: {
model: 'SubscriptionLevel',
labelField: 'name',
valueField: 'id'
}
}
},
@@ -134,14 +159,39 @@ const models: AdminModelConfig[] = [
readonlyFields: ['id'],
fieldTypes: {
name: 'String',
value: 'Float'
value: 'Int'
},
relatedRecords: [
{
relationKey: 'proclamations',
label: 'Proclamations',
type: 'has-many',
displayFields: ['name', 'price'],
displayFields: ['name'],
editSlug: 'proclamations'
}
]
},
{
slug: 'subscription-levels',
modelName: 'SubscriptionLevel',
displayName: 'Subscription Levels',
displayNameSingular: 'Subscription Level',
idType: 'Int',
include: { proclamations: true },
listFields: ['id', 'name', 'description'],
searchFields: ['name', 'description'],
formFields: ['name', 'description'],
readonlyFields: ['id'],
fieldTypes: {
name: 'String',
description: 'String'
},
relatedRecords: [
{
relationKey: 'proclamations',
label: 'Proclamations',
type: 'has-many',
displayFields: ['name'],
editSlug: 'proclamations'
}
]
@@ -152,14 +202,15 @@ const models: AdminModelConfig[] = [
displayName: 'Proclamation Categories',
displayNameSingular: 'Proclamation Category',
idType: 'Int',
include: { proclamations: true, children: true, parent: true },
listFields: ['displayName', 'parent.name'],
include: { proclamations: true, children: true },
listFields: ['name'],
searchFields: ['name'],
formFields: ['name', 'parentId'],
readonlyFields: ['id'],
fieldTypes: {
name: 'String',
parentId: 'Int'
parentId: 'Int',
sortOrder: 'Int'
},
relationOptions: {
parentId: { model: 'ProclamationCategory', labelField: 'name', valueField: 'id' }
@@ -169,7 +220,7 @@ const models: AdminModelConfig[] = [
relationKey: 'proclamations',
label: 'Proclamations',
type: 'has-many',
displayFields: ['name', 'price'],
displayFields: ['name'],
editSlug: 'proclamations'
},
{
@@ -188,7 +239,6 @@ const models: AdminModelConfig[] = [
displayNameSingular: 'License Key',
idType: 'String',
include: { user: true },
listOnly: true,
listFields: ['key', 'active', 'level', 'user.name'],
searchFields: ['key', 'level'],
formFields: ['active', 'level', 'userId'],
+74
View File
@@ -0,0 +1,74 @@
import { z } from 'zod';
const email = z.string().email('Invalid email address');
const phone = z
.string()
.min(10, 'Phone number must be at least 10 digits')
.regex(/^[\d\-\+\(\) ]+$/, 'Invalid phone number format');
const name = z.string().min(1, 'Required').max(100, 'Too long');
export const UserRole = z.object({
name: z.string().min(1, 'Required')
});
export const User = z.object({
name: z.string().min(1, 'Required'),
email,
roleId: z.coerce.number().int().min(1)
});
export const Profile = z.object({
userId: z.string().min(1, 'Required'),
firstName: name,
lastName: name,
phoneNumber: phone
});
export const Address = z.object({
profileId: z.string().min(1),
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(1, 'State is required'),
postalCode: z.string().min(1, 'Postal code is required'),
country: z.string().min(1, 'Country is required'),
type: z.enum(['Mailing', 'Billing', 'Shipping']).default('Mailing'),
isDefaultMailing: z.coerce.boolean().default(false),
isDefaultBilling: z.coerce.boolean().default(false),
isDefaultShipping: z.coerce.boolean().default(false)
});
export const SolfeggioFrequency = z.object({
name: z.string().min(1, 'Required'),
value: z.coerce.number().int('Must be a whole number')
});
export const SubscriptionLevel = z.object({
name: z.string().min(1, 'Required'),
description: z.string().optional().default('')
});
export const ProclamationCategory = z.object({
name: z.string().min(1, 'Required'),
parentId: z.coerce.number().int().optional()
});
export const Proclamation = z.object({
name: z.string().min(1, 'Required'),
solfeggioFrequencyId: z.coerce.number().int(),
parentCategoryId: z.coerce.number().int(),
categoryId: z.coerce.number().int().optional(),
subscriptionLevelId: z.coerce.number().int(),
content: z.string().optional().default('')
});
export const LicenseKey = z.object({
active: z.coerce.boolean(),
level: z.string().min(1, 'Required'),
userId: z.string().min(1, 'Required')
});
export const UserUpdate = z.object({
id: z.string().min(1),
name: z.string().min(1, 'Required').optional(),
email: z.string().email().optional()
});
+9
View File
@@ -19,6 +19,12 @@ export type RelatedRecordConfig = {
editSlug?: string;
};
export type ActionResult = {
success: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export type AdminModelConfig = {
slug: string;
modelName: string;
@@ -35,4 +41,7 @@ export type AdminModelConfig = {
relationOptions?: Record<string, RelationOptionConfig>;
relatedRecords?: RelatedRecordConfig[];
accessFilter?: (viewerRoleId: number) => Record<string, unknown> | undefined;
/** Fields that should be excluded from Prisma create/update (virtual/UI-only) */
dataOnlyFields?: string[];
cascadeFields?: Record<string, { parentField: string }>;
};
@@ -23,26 +23,30 @@
}
</script>
<div class="flex h-full min-h-0 flex-col gap-8 p-6">
<h1 class="text-2xl font-semibold text-gray-900">Account</h1>
<div class="flex h-full min-h-0 flex-col gap-8 p-6 dark:text-gray-100">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Account</h1>
<!-- Email (read-only) -->
<div>
<p class="text-sm font-medium text-gray-700">Email</p>
<p class="mt-1 text-gray-900">{data.user.email}</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Email</p>
<p class="mt-1 text-gray-900 dark:text-gray-100">{data.user.email}</p>
</div>
<!-- Profile Form -->
<section>
<h2 class="mb-4 text-lg font-medium text-gray-900">Profile</h2>
<h2 class="mb-4 text-lg font-medium text-gray-900 dark:text-gray-100">Profile</h2>
{#if form?.action === 'updateProfile' && form?.success}
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
<div
class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
Profile updated successfully.
</div>
{/if}
{#if form?.action === 'updateProfile' && form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
<div
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{form.error}
</div>
{/if}
@@ -50,7 +54,7 @@
<form method="POST" action="?/updateProfile" class="flex max-w-lg flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="firstName" class="block text-sm font-medium text-gray-700">
<label for="firstName" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
First Name
</label>
<input
@@ -59,11 +63,11 @@
name="firstName"
value={data.user.profile?.firstName ?? ''}
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
<div>
<label for="lastName" class="block text-sm font-medium text-gray-700">
<label for="lastName" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Last Name
</label>
<input
@@ -72,12 +76,12 @@
name="lastName"
value={data.user.profile?.lastName ?? ''}
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
</div>
<div>
<label for="phoneNumber" class="block text-sm font-medium text-gray-700">
<label for="phoneNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Phone Number
</label>
<input
@@ -87,13 +91,13 @@
bind:value={phoneNumber}
oninput={handlePhoneInput}
required
class="mt-1 w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
class="mt-1 w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
<div>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
>
Save Profile
</button>
@@ -104,10 +108,10 @@
<!-- Addresses -->
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-medium text-gray-900">Addresses</h2>
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">Addresses</h2>
<button
type="button"
class="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-700"
class="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
onclick={() => (showAddressForm = !showAddressForm)}
>
{showAddressForm ? 'Cancel' : 'Add Address'}
@@ -115,12 +119,16 @@
</div>
{#if form?.action === 'addAddress' && form?.success}
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
<div
class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
Address added successfully.
</div>
{/if}
{#if form?.action === 'addAddress' && form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
<div
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{form.error}
</div>
{/if}
@@ -129,44 +137,53 @@
<form
method="POST"
action="?/addAddress"
class="mb-6 max-w-lg rounded-lg border border-gray-200 bg-gray-50 p-4"
class="mb-6 max-w-lg rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex flex-col gap-3">
<div>
<label for="street" class="block text-sm font-medium text-gray-700">Street</label>
<label for="street" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Street</label
>
<input
type="text"
id="street"
name="street"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="city" class="block text-sm font-medium text-gray-700">City</label>
<label for="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>City</label
>
<input
type="text"
id="city"
name="city"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
<div>
<label for="state" class="block text-sm font-medium text-gray-700">State</label>
<label for="state" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>State</label
>
<input
type="text"
id="state"
name="state"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="postalCode" class="block text-sm font-medium text-gray-700">
<label
for="postalCode"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Postal Code
</label>
<input
@@ -174,25 +191,28 @@
id="postalCode"
name="postalCode"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
<div>
<label for="country" class="block text-sm font-medium text-gray-700">Country</label>
<label
for="country"
class="block text-sm font-medium text-gray-700 dark:text-gray-300">Country</label
>
<input
type="text"
id="country"
name="country"
required
value="US"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
</div>
<div>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
>
Save Address
</button>
@@ -204,17 +224,20 @@
{#if data.user.profile?.addresses && data.user.profile.addresses.length > 0}
<div class="flex flex-col gap-3">
{#each data.user.profile.addresses as address}
<div class="rounded-lg border border-gray-200 bg-white p-4">
<p class="text-sm text-gray-900">{address.street}</p>
<p class="text-sm text-gray-600">
{address.city}, {address.state} {address.postalCode}
<div
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900"
>
<p class="text-sm text-gray-900 dark:text-gray-100">{address.street}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{address.city}, {address.state}
{address.postalCode}
</p>
<p class="text-sm text-gray-600">{address.country}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{address.country}</p>
</div>
{/each}
</div>
{:else if !showAddressForm}
<p class="text-sm text-gray-500">No addresses on file.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">No addresses on file.</p>
{/if}
</section>
</div>
@@ -168,9 +168,9 @@ describe('(protected)/account page server', () => {
country: 'US'
});
const err = await Promise.resolve(
invokeAction('addAddress', request, createLocals())
).catch((e: unknown) => e);
const err = await Promise.resolve(invokeAction('addAddress', request, createLocals())).catch(
(e: unknown) => e
);
expect(isHttpError(err, 403)).toBe(true);
});
@@ -201,9 +201,9 @@ describe('(protected)/account page server', () => {
});
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(
invokeAction('addAddress', request, locals)
).catch((e: unknown) => e);
const err = await Promise.resolve(invokeAction('addAddress', request, locals)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 400)).toBe(true);
});
@@ -37,7 +37,7 @@ export const actions: Actions = {
if (profile) {
console.log(`created profile: ${JSON.stringify(profile)}`);
throw redirect(303, '/account');
throw redirect(303, '/dashboard');
} else {
return fail(400);
}
@@ -0,0 +1,82 @@
<!--
There should be a form in the middle
rounded corners
floats above the screen slightly
Profile picture in the center at the top, about 1/3-1/2 the width of the form
Their email address, not editable, instead is a regular sized black text (or white in dark mode)
Input field for their Phone Number
Two inputs, side by side, a little margin between them. First and Last name
A submit button
Save the form then redirect them to their dashboard
-->
<script lang="ts">
import Window from '$lib/components/containers/Window.svelte';
import { clientFormatPhoneNumber } from '$lib/utils';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let firstName = $state('');
let lastName = $state('');
function splitName(fullName: string | null | undefined): { first: string; last: string } {
if (!fullName) return { first: '', last: '' };
const parts = fullName.trim().split(/\s+/);
return { first: parts[0] ?? '', last: parts.slice(1).join(' ') };
}
$effect(() => {
if (data.user?.name && !firstName) {
const names = splitName(data.user.name);
firstName = names.first;
lastName = names.last;
}
});
let phoneNumber = $state('');
function handlePhoneInput(event: Event) {
const input = event.target as HTMLInputElement;
phoneNumber = clientFormatPhoneNumber(input.value);
}
</script>
<Window>
<form method="POST" class="border p-6 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100">
<p class="mx-auto my-4 text-center italic">Please finish setting up your profile</p>
{#if data.user?.image}
<img src={data.user?.image} class="mx-auto w-[8rem] rounded-full" alt="Profile" />
{:else}
<div class="mx-auto aspect-square w-[8rem] rounded-full bg-slate-900"></div>
{/if}
<p class="mx-auto my-4 text-center">{data.user?.email}</p>
<div class="flex flex-col">
<input type="text" name="userId" value={data.user?.id} class="hidden" />
<input
type="text"
name="firstName"
placeholder="First Name"
class="my-2"
bind:value={firstName}
/>
<input
type="text"
name="lastName"
placeholder="Last Name"
class="my-2"
bind:value={lastName}
/>
<input
type="tel"
name="phoneNumber"
placeholder="Phone Number"
class="my-2"
bind:value={phoneNumber}
oninput={handlePhoneInput}
/>
</div>
<button type="submit" class="bg-blue-300 p-2 px-4 dark:bg-gray-700 dark:text-gray-100"
>Submit</button
>
</form>
</Window>
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError, isRedirect, isActionFailure, redirect } from '@sveltejs/kit';
import { createLocals, mockUser, createFormRequest } from '$lib/test-utils';
import { serverFormatPhoneNumber } from '$lib/utils';
const { create, findUnique } = vi.hoisted(() => ({
create: vi.fn(),
findUnique: vi.fn()
}));
vi.mock('$lib/db', () => ({
prisma: {
user: {
findUnique
},
profile: {
create
}
}
}));
vi.mock('$lib/utils', () => ({
serverFormatPhoneNumber: vi.fn((val: string) => `formatted-${val}`)
}));
import { load, actions } from './+page.server';
const invokeDefault = (request: Request) => actions.default({ request } as any);
describe('account setup page server load', () => {
beforeEach(() => {
create.mockReset();
});
it('throws 403 when user is not logged in', async () => {
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns user data when logged in', async () => {
const user = mockUser({ id: 'user-1', name: 'Jane Doe' });
findUnique.mockResolvedValue(user);
const result = await load({ locals: createLocals(user as any) } as any);
expect(result).toEqual({ user });
});
});
describe('account setup page server action', () => {
beforeEach(() => {
create.mockReset();
});
it('redirects to /dashboard on successful profile creation', async () => {
create.mockResolvedValue({ id: 'profile-1', userId: 'user-1' });
const request = createFormRequest({
userId: 'user-1',
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: '555-0100'
});
const err = await Promise.resolve(invokeDefault(request)).catch((e: unknown) => e);
expect(isRedirect(err)).toBe(true);
if (isRedirect(err)) {
expect(err.status).toBe(303);
expect(err.location).toBe('/dashboard');
}
expect(serverFormatPhoneNumber).toHaveBeenCalledWith('555-0100');
expect(create).toHaveBeenCalledWith({
data: {
userId: 'user-1',
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: 'formatted-555-0100'
}
});
});
it('returns fail(400) when profile.create returns falsy', async () => {
create.mockResolvedValue(null);
const request = createFormRequest({
userId: 'user-1',
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: '555-0100'
});
const result = await invokeDefault(request);
expect(isActionFailure(result)).toBe(true);
});
it('throws 400 error when profile.create throws', async () => {
create.mockRejectedValue(new Error('db error'));
const request = createFormRequest({
userId: 'user-1',
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: '555-0100'
});
const err = await Promise.resolve(invokeDefault(request)).catch((e: unknown) => e);
expect(isHttpError(err, 400)).toBe(true);
if (isHttpError(err)) {
expect(err.body.message).toBe('bad request');
}
});
it('re-throws redirect errors from profile.create', async () => {
let redirectError: unknown;
try {
redirect(303, '/dashboard');
} catch (e) {
redirectError = e;
}
create.mockRejectedValue(redirectError);
const request = createFormRequest({
userId: 'user-1',
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: '555-0100'
});
const err = await Promise.resolve(invokeDefault(request)).catch((e: unknown) => e);
expect(err).toBe(redirectError);
});
});
@@ -4,35 +4,39 @@
let { data }: PageProps = $props();
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
<h1 class="text-2xl font-semibold text-gray-900">Welcome, {data.firstName}!</h1>
<div class="flex h-full min-h-0 flex-col gap-6 p-6 dark:text-gray-100">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
Welcome, {data.firstName}!
</h1>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<a
href="/license-keys"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
>
<p class="text-sm font-medium text-gray-500">Active License Keys</p>
<p class="mt-2 text-3xl font-bold text-gray-900">{data.activeKeys}</p>
<p class="mt-1 text-sm text-gray-400">{data.totalKeys} total</p>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Active License Keys</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">{data.activeKeys}</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">{data.totalKeys} total</p>
</a>
<a
href="/admin/proclamations"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
>
<p class="text-sm font-medium text-gray-500">Proclamations</p>
<p class="mt-2 text-3xl font-bold text-gray-900">{data.proclamationCount}</p>
<p class="mt-1 text-sm text-gray-400">Browse all</p>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Proclamations</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">
{data.proclamationCount}
</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Browse all</p>
</a>
<a
href="/account"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
>
<p class="text-sm font-medium text-gray-500">Account</p>
<p class="mt-2 text-lg font-semibold text-gray-900">Manage Profile</p>
<p class="mt-1 text-sm text-gray-400">Edit your details</p>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Account</p>
<p class="mt-2 text-lg font-semibold text-gray-900 dark:text-gray-100">Manage Profile</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Edit your details</p>
</a>
</div>
</div>
@@ -4,39 +4,47 @@
let { data }: PageProps = $props();
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
<h1 class="text-2xl font-semibold text-gray-900">License Keys</h1>
<div class="flex h-full min-h-0 flex-col gap-6 p-6 dark:text-gray-100">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">License Keys</h1>
{#if data.licenseKeys.length > 0}
<div class="overflow-x-auto rounded-lg border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
>
Key
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
>
Level
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
>
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
{#each data.licenseKeys as key}
<tr>
<td class="whitespace-nowrap px-6 py-4 font-mono text-sm text-gray-900">
<td
class="px-6 py-4 font-mono text-sm whitespace-nowrap text-gray-900 dark:text-gray-100"
>
{key.key}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-700">
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300">
{key.level}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
<td class="px-6 py-4 text-sm whitespace-nowrap">
<span
class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 {key.active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'}"
class="inline-flex rounded-full px-2 text-xs leading-5 font-semibold {key.active
? 'bg-green-100 text-green-800 dark:bg-gray-700 dark:text-gray-200'
: 'bg-red-100 text-red-800 dark:bg-gray-700 dark:text-gray-200'}"
>
{key.active ? 'Active' : 'Inactive'}
</span>
@@ -48,7 +56,7 @@
</div>
{:else}
<div class="flex flex-1 items-center justify-center">
<p class="text-lg text-gray-500">You don't have any license keys yet.</p>
<p class="text-lg text-gray-500 dark:text-gray-400">You don't have any license keys yet.</p>
</div>
{/if}
</div>
@@ -23,7 +23,11 @@ export const load: PageServerLoad = async ({ url }) => {
const [proclamations, categories] = await Promise.all([
prisma.proclamation.findMany({
where,
include: { solfeggioFrequency: true, category: { include: { parent: true } } },
include: {
solfeggioFrequency: true,
category: { include: { parent: true } },
subscriptionLevel: true
},
orderBy: { name: 'asc' }
}),
prisma.proclamationCategory.findMany({ include: { parent: true }, orderBy: { name: 'asc' } })
@@ -34,29 +34,33 @@
}
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
<h1 class="text-2xl font-semibold text-gray-900">Proclamations</h1>
<div class="flex h-full min-h-0 flex-col gap-6 p-6 dark:text-gray-100">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Proclamations</h1>
<!-- Filters -->
<div class="flex flex-wrap items-end gap-4">
<div>
<label for="search" class="block text-sm font-medium text-gray-700">Search</label>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Search</label
>
<input
type="text"
id="search"
bind:value={search}
placeholder="Search by name..."
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
onkeydown={(e) => e.key === 'Enter' && applyFilters()}
/>
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700">Category</label>
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Category</label
>
<select
id="category"
bind:value={categoryId}
onchange={applyFilters}
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
>
<option value={null}>All Categories</option>
{#each data.categories as cat}
@@ -66,7 +70,7 @@
</div>
<button
type="button"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
onclick={applyFilters}
>
Search
@@ -74,7 +78,7 @@
{#if data.search || data.categoryId}
<button
type="button"
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
onclick={clearFilters}
>
Clear
@@ -88,17 +92,19 @@
{#each data.proclamations as proclamation}
<a
href="/proclamations/{proclamation.id}"
class="flex flex-col rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md"
class="flex flex-col rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
>
<h2 class="text-lg font-medium text-gray-900">{proclamation.name}</h2>
<p class="mt-1 text-sm text-gray-500">{displayCategoryName(proclamation.category)}</p>
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">{proclamation.name}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{displayCategoryName(proclamation.category)}
</p>
<div class="mt-auto flex items-center justify-between pt-4">
<span class="text-sm text-purple-600">
<span class="text-sm text-purple-600 dark:text-gray-400">
{proclamation.solfeggioFrequency.value} Hz &mdash;
{proclamation.solfeggioFrequency.name}
</span>
<span class="font-semibold text-gray-900">
${proclamation.price.toFixed(2)}
<span class="text-xs text-gray-500 dark:text-gray-400">
{proclamation.subscriptionLevel?.name ?? 'Standard'}
</span>
</div>
</a>
@@ -106,7 +112,7 @@
</div>
{:else}
<div class="flex flex-1 items-center justify-center">
<p class="text-lg text-gray-500">No proclamations found.</p>
<p class="text-lg text-gray-500 dark:text-gray-400">No proclamations found.</p>
</div>
{/if}
</div>
@@ -14,7 +14,8 @@ export const load: PageServerLoad = async ({ params, parent }) => {
where: { id: params.id },
include: {
solfeggioFrequency: true,
category: { include: { parent: true } }
category: { include: { parent: true } },
subscriptionLevel: true
}
});
@@ -10,36 +10,44 @@
);
</script>
<div class="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col gap-6 p-6">
<div class="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col gap-6 p-6 dark:text-gray-100">
<a
href="/proclamations"
class="text-sm text-blue-600 hover:text-blue-800 hover:underline"
class="text-sm text-blue-600 hover:text-blue-800 hover:underline dark:text-gray-400 dark:hover:text-gray-200"
>
&larr; Back to Proclamations
</a>
<div>
<h1 class="text-2xl font-semibold text-gray-900">{data.proclamation.name}</h1>
<p class="mt-1 text-sm text-gray-500">{categoryBreadcrumb}</p>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{data.proclamation.name}
</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{categoryBreadcrumb}</p>
</div>
<div class="flex flex-wrap gap-4">
<div class="rounded-lg border border-purple-200 bg-purple-50 px-4 py-2">
<p class="text-xs font-medium text-purple-600">Frequency</p>
<p class="text-sm font-semibold text-purple-900">
<div
class="rounded-lg border border-purple-200 bg-purple-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-xs font-medium text-purple-600 dark:text-gray-400">Frequency</p>
<p class="text-sm font-semibold text-purple-900 dark:text-gray-100">
{data.proclamation.solfeggioFrequency.value} Hz &mdash;
{data.proclamation.solfeggioFrequency.name}
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-2">
<p class="text-xs font-medium text-gray-500">Price</p>
<p class="text-sm font-semibold text-gray-900">
${data.proclamation.price.toFixed(2)}
<div
class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Level</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{data.proclamation.subscriptionLevel?.name ?? 'Standard'}
</p>
</div>
</div>
<div class="prose max-w-none rounded-lg border border-gray-200 bg-white p-6">
<div
class="prose max-w-none rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900 dark:prose-invert"
>
{@html data.proclamation.content}
</div>
</div>
@@ -21,9 +21,9 @@ describe('(protected)/admin/proclamations/[id] page server load', () => {
it('throws 404 when proclamation is not found', async () => {
findUnique.mockResolvedValue(null);
const err = await Promise.resolve(
load({ params: { id: 'nonexistent' } } as any)
).catch((e: unknown) => e);
const err = await Promise.resolve(load({ params: { id: 'nonexistent' } } as any)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 404)).toBe(true);
});
@@ -44,7 +44,8 @@ describe('(protected)/admin/proclamations/[id] page server load', () => {
where: { id: 'proc-1' },
include: {
solfeggioFrequency: true,
category: { include: { parent: true } }
category: { include: { parent: true } },
subscriptionLevel: true
}
});
});
@@ -29,10 +29,12 @@ const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<type
const url = createUrl('/admin/proclamations');
const result = (await load({ url } as any)) as any;
expect(result.proclamations).toEqual(proclamations);
expect(result.categories[0]).toEqual(expect.objectContaining({
expect(result.categories[0]).toEqual(
expect.objectContaining({
...categories[0],
displayName: categories[0].name
}));
})
);
expect(result.search).toBe('');
expect(result.categoryId).toBeNull();
});
@@ -109,7 +111,11 @@ const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<type
expect(proclamationFindMany).toHaveBeenCalledWith(
expect.objectContaining({
include: { solfeggioFrequency: true, category: { include: { parent: true } } }
include: {
solfeggioFrequency: true,
category: { include: { parent: true } },
subscriptionLevel: true
}
})
);
});
+8 -3
View File
@@ -2,15 +2,20 @@ import type { LayoutServerLoad } from './$types';
import { error, redirect } from '@sveltejs/kit';
import { prisma } from '$lib/db';
export const load: LayoutServerLoad = async ({ locals }) => {
export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const user = await prisma.user.findUnique({
where: { id: locals.user?.id },
where: { id: locals.user.id },
select: { id: true, roleId: true, profile: { select: { id: true } } }
});
if (!user) {
throw error(403, 'not authorized');
}
if (!user.profile) {
if (!user.profile && !url.pathname.startsWith('/account/setup')) {
// Skip redirect when already on /account/setup to avoid redirect loop
throw redirect(303, '/account/setup');
}
+15 -9
View File
@@ -28,8 +28,8 @@
<a
href={link.href}
class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive(link.href)
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}"
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
>
{link.label}
</a>
@@ -39,8 +39,8 @@
<a
href="/admin"
class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive('/admin')
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'}"
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
>
Admin Panel
</a>
@@ -48,15 +48,21 @@
{#each adminModels as model}
<a
href="/admin/{model.slug}"
class="rounded px-3 py-1 text-sm transition-colors {isActive(
'/admin/' + model.slug
)
? 'bg-blue-50 text-blue-700'
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800'}"
class="rounded px-3 py-1 text-sm transition-colors {isActive('/admin/' + model.slug)
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
>
{model.displayName}
</a>
{/each}
<a
href="/admin/api-keys"
class="rounded px-3 py-1 text-sm transition-colors {isActive('/admin/api-keys')
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
>
API Keys
</a>
</div>
</div>
{/if}
@@ -18,7 +18,9 @@ export const load: LayoutServerLoad = async ({ locals }) => {
});
if (!user || !canAccessAdmin(user.roleId)) {
console.log(`Attempted to access /admin via ${user?.id} : ${user?.name} with a role of ${user?.role}`);
console.log(
`Attempted to access /admin via ${user?.id} : ${user?.name} with a role of ${user?.role}`
);
throw error(403, 'not authorized');
}
@@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
import { getAllModelConfigs } from '$lib/svelte-admin/registry';
export const load: PageServerLoad = async () => {
const models = getAllModelConfigs().filter((m) => m.slug !== 'license-keys');
return { models };
};
+21 -1
View File
@@ -1 +1,21 @@
<h1>Admin Dashboard</h1>
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let models = $derived(data.models);
</script>
<h1 class="p-6 text-xl font-bold text-gray-900 dark:text-gray-100">Admin Dashboard</h1>
<div class="grid grid-cols-1 gap-4 px-6 pb-6 sm:grid-cols-2 lg:grid-cols-3">
{#each models as model (model.slug)}
<a
href={'/admin/' + model.slug}
class="block border border-gray-200 p-4 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{model.displayName}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{model.listFields.length} fields
</p>
</a>
{/each}
</div>
@@ -0,0 +1,72 @@
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { rotateApiKey } from '$lib/server/api-key';
import { ROLES } from '$lib/svelte-admin/roles';
export const load: PageServerLoad = async ({ locals, parent }) => {
const { adminUser } = await parent();
if (adminUser.roleId > ROLES.SUPER) {
throw error(403, 'Only Super admins can manage API keys');
}
const now = new Date();
const activeKey = await prisma.apiKey.findFirst({
where: {
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }]
},
select: { keyPrefix: true, createdAt: true, expiresAt: true },
orderBy: { createdAt: 'desc' }
});
const expiredCount = await prisma.apiKey.count({
where: {
OR: [{ revokedAt: { not: null, lte: now } }, { expiresAt: { not: null, lte: now } }]
}
});
return {
activeKey: activeKey
? {
prefix: activeKey.keyPrefix,
createdAt: activeKey.createdAt.toISOString(),
expiresAt: activeKey.expiresAt?.toISOString() ?? null
}
: null,
totalRotated: expiredCount
};
};
async function viewerRoleId(locals: App.Locals): Promise<number> {
if (!locals.user?.id) return 4;
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
select: { roleId: true }
});
return user?.roleId ?? 4;
}
export const actions = {
rotate: async ({ locals }) => {
if ((await viewerRoleId(locals)) > ROLES.SUPER) {
return { success: false, error: 'Not authorized' };
}
const result = await rotateApiKey(false);
return {
success: true,
newKey: result.plaintext,
mode: 'normal' as const,
expiresAt: result.previousExpiresAt?.toISOString() ?? null
};
},
rotateEmergency: async ({ locals }) => {
if ((await viewerRoleId(locals)) > ROLES.SUPER) {
return { success: false, error: 'Not authorized' };
}
const result = await rotateApiKey(true);
return { success: true, newKey: result.plaintext, mode: 'emergency' as const };
}
};
@@ -0,0 +1,148 @@
<script lang="ts">
import type { PageProps } from './$types';
import { enhance } from '$app/forms';
let { data }: PageProps = $props();
let newKey = $state('');
let mode = $state<'normal' | 'emergency' | null>(null);
let expiryDate = $state<string | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
function handleRotate(emergency: boolean) {
return ({ cancel }: any) => {
const msg = emergency
? 'EMERGENCY ROTATION: The current key will be revoked IMMEDIATELY. Continue?'
: 'Rotate the API key? The current key will remain valid for 48 hours.';
if (!confirm(msg)) {
cancel();
return;
}
return async ({ result }: any) => {
if (result.type === 'success' && result.status === 200) {
if (result.data?.newKey) {
newKey = result.data.newKey;
mode = result.data.mode ?? null;
expiryDate = result.data.expiresAt ?? null;
}
} else {
toastMessage = 'Rotation failed';
toastType = 'error';
}
};
};
}
function copyKey() {
navigator.clipboard.writeText(newKey);
toastMessage = 'Copied to clipboard';
toastType = 'success';
}
function dismissKey() {
newKey = '';
mode = null;
expiryDate = null;
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">API Key Management</h1>
{#if newKey}
<div
class="mb-6 border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/30"
>
<p class="mb-2 text-sm font-semibold text-amber-800 dark:text-amber-300">
{mode === 'emergency' ? 'Emergency Rotation Complete' : 'New API Key Generated'}
</p>
<p class="mb-1 text-xs text-amber-700 dark:text-amber-400">
This key will not be shown again. Copy it now.
</p>
<div class="mb-3">
<code
class="rounded border border-amber-300 bg-white px-3 py-2 font-mono text-sm break-all dark:border-amber-600 dark:bg-gray-900 dark:text-gray-100"
>
{newKey}
</code>
</div>
<div class="flex gap-2">
<button
type="button"
onclick={copyKey}
class="border border-amber-300 bg-white px-3 py-1.5 text-sm font-medium hover:bg-amber-100 dark:border-amber-600 dark:bg-gray-800 dark:text-amber-300 dark:hover:bg-amber-900/50"
>
Copy to Clipboard
</button>
<button
type="button"
onclick={dismissKey}
class="border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:text-gray-300"
>
Dismiss
</button>
</div>
{#if mode === 'normal' && expiryDate}
<p class="mt-3 text-xs text-amber-700 dark:text-amber-400">
The previous key will remain valid until {new Date(expiryDate).toLocaleString()}
to give in-field devices time to update.
</p>
{/if}
</div>
{/if}
{#if toastMessage}
<div
class="mb-4 border px-4 py-2 text-sm {toastType === 'success'
? 'border-green-300 bg-green-50 text-green-700'
: 'border-red-300 bg-red-50 text-red-700'}"
>
{toastMessage}
</div>
{/if}
<div class="mb-6 rounded border border-gray-200 p-4 dark:border-gray-700">
<h2 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Current Key Status</h2>
{#if data.activeKey}
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<div>
Prefix: <code class="font-mono font-medium text-gray-900 dark:text-gray-100"
>{data.activeKey.prefix}</code
>
</div>
<div>Created: {new Date(data.activeKey.createdAt).toLocaleString()}</div>
{#if data.activeKey.expiresAt}
<div>Expires: {new Date(data.activeKey.expiresAt).toLocaleString()}</div>
{/if}
</div>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">No active API key. Generate one below.</p>
{/if}
{#if data.totalRotated > 0}
<p class="mt-2 text-xs text-gray-400">
{data.totalRotated} previous key(s) have been rotated.
</p>
{/if}
</div>
<div class="flex flex-wrap gap-3">
<form method="POST" action="?/rotate" use:enhance={handleRotate(false)}>
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-4 py-2 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Generate New API Key
</button>
</form>
<form method="POST" action="?/rotateEmergency" use:enhance={handleRotate(true)}>
<button
type="submit"
class="border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
>
Emergency Rotation
</button>
</form>
</div>
</div>
@@ -1,10 +0,0 @@
import type { PageServerLoad } from "./$types";
import { prisma } from "$lib/db";
export const load: PageServerLoad = async ({ locals }) => {
console.log(locals.user?.name);
// Load proclamation categories
const categories = await prisma.proclamationCategory.findMany();
console.log(categories);
return { categories };
}

Some files were not shown because too many files have changed in this diff Show More