diff --git a/.agents/skills/better-auth-best-practices/SKILL.md b/.agents/skills/better-auth-best-practices/SKILL.md new file mode 100644 index 0000000..3e6a4e1 --- /dev/null +++ b/.agents/skills/better-auth-best-practices/SKILL.md @@ -0,0 +1,175 @@ +--- +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()`. + +--- + +## 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) \ No newline at end of file diff --git a/.agents/skills/better-auth-security-best-practices/SKILL.MD b/.agents/skills/better-auth-security-best-practices/SKILL.MD new file mode 100644 index 0000000..5abc5a8 --- /dev/null +++ b/.agents/skills/better-auth-security-best-practices/SKILL.MD @@ -0,0 +1,432 @@ +--- +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 diff --git a/.agents/skills/create-auth-skill/SKILL.md b/.agents/skills/create-auth-skill/SKILL.md new file mode 100644 index 0000000..515e6dd --- /dev/null +++ b/.agents/skills/create-auth-skill/SKILL.md @@ -0,0 +1,321 @@ +--- +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= +``` + +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) diff --git a/.agents/skills/email-and-password-best-practices/SKILL.md b/.agents/skills/email-and-password-best-practices/SKILL.md new file mode 100644 index 0000000..537c010 --- /dev/null +++ b/.agents/skills/email-and-password-best-practices/SKILL.md @@ -0,0 +1,212 @@ +--- +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. diff --git a/.agents/skills/organization-best-practices/SKILL.md b/.agents/skills/organization-best-practices/SKILL.md new file mode 100644 index 0000000..0e84a84 --- /dev/null +++ b/.agents/skills/organization-best-practices/SKILL.md @@ -0,0 +1,479 @@ +--- +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: ` +

${inviter.user.name} invited you to join ${organization.name}

+ + Accept Invitation + + `, + }); + }, + }), + ], +}); +``` + +### 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: `Accept`, + }); + }, + + // Hooks + hooks: { + organization: { + afterCreate: async ({ organization }) => { + console.log(`Organization ${organization.name} created`); + }, + }, + }, + }), + ], +}); +``` diff --git a/.agents/skills/two-factor-authentication-best-practices/SKILL.md b/.agents/skills/two-factor-authentication-best-practices/SKILL.md new file mode 100644 index 0000000..cf9c30b --- /dev/null +++ b/.agents/skills/two-factor-authentication-best-practices/SKILL.md @@ -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 ; +}; +``` + +### 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 ( +
+

Save these codes in a secure location:

+
    + {codes.map((code, i) => ( +
  • {code}
  • + ))} +
+
+ ); +}; +``` + +### 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 + }), + ], +}); +``` diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..76b4361 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,123 @@ +# Ultracite Code Standards + +This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting. + +## Quick Reference + +- **Format code**: `pnpm dlx ultracite fix` +- **Check for issues**: `pnpm dlx ultracite check` +- **Diagnose setup**: `pnpm dlx ultracite doctor` + +Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable. + +--- + +## Core Principles + +Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity. + +### Type Safety & Explicitness + +- Use explicit types for function parameters and return values when they enhance clarity +- Prefer `unknown` over `any` when the type is genuinely unknown +- Use const assertions (`as const`) for immutable values and literal types +- Leverage TypeScript's type narrowing instead of type assertions +- Use meaningful variable names instead of magic numbers - extract constants with descriptive names + +### Modern JavaScript/TypeScript + +- Use arrow functions for callbacks and short functions +- Prefer `for...of` loops over `.forEach()` and indexed `for` loops +- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access +- Prefer template literals over string concatenation +- Use destructuring for object and array assignments +- Use `const` by default, `let` only when reassignment is needed, never `var` + +### Async & Promises + +- Always `await` promises in async functions - don't forget to use the return value +- Use `async/await` syntax instead of promise chains for better readability +- Handle errors appropriately in async code with try-catch blocks +- Don't use async functions as Promise executors + +### React & JSX + +- Use function components over class components +- Call hooks at the top level only, never conditionally +- Specify all dependencies in hook dependency arrays correctly +- Use the `key` prop for elements in iterables (prefer unique IDs over array indices) +- Nest children between opening and closing tags instead of passing as props +- Don't define components inside other components +- Use semantic HTML and ARIA attributes for accessibility: + - Provide meaningful alt text for images + - Use proper heading hierarchy + - Add labels for form inputs + - Include keyboard event handlers alongside mouse events + - Use semantic elements (` -
+
@@ -115,10 +115,10 @@ export default async function MeetsPage({
Summer Invitational
Registered -
+
July 20, 2023 - Riverside Swim Club
-
+
Prepare for the big event!
@@ -127,10 +127,10 @@ export default async function MeetsPage({
Winter Classic
Registered
-
+
December 10, 2023 - Northside Swim Center
-
+
Get ready for the winter season!
@@ -149,10 +149,10 @@ export default async function MeetsPage({
Regional Championships
Completed
-
+
June 15, 2023 - Olympia Aquatic Center
-
+
Great performance by the team!
@@ -161,10 +161,10 @@ export default async function MeetsPage({
Fall Championships
Cancelled
-
+
September 5, 2023 - Aquatic Center of Excellence
-
+
Unfortunate cancellation due to weather.
diff --git a/apps/admin/app/(authenticated)/team/[teamId]/page.tsx b/apps/admin/app/(authenticated)/team/[teamId]/page.tsx new file mode 100644 index 0000000..f9f4c92 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/page.tsx @@ -0,0 +1,382 @@ +import { Badge } from "@project-aqua/design-system/components/ui/badge"; +import { Button } from "@project-aqua/design-system/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@project-aqua/design-system/components/ui/card"; +import { Progress } from "@project-aqua/design-system/components/ui/progress"; +import { Separator } from "@project-aqua/design-system/components/ui/separator"; +import { + AlertCircleIcon, + CalendarIcon, + CheckCircle2Icon, + ChevronRightIcon, + ClipboardListIcon, + ClockIcon, + TrendingUpIcon, + TrophyIcon, + UploadIcon, + UsersIcon, + WavesIcon, + XCircleIcon, +} from "lucide-react"; +import type { Metadata, ResolvingMetadata } from "next"; +import Link from "next/link"; +import { RecentDropsTable } from "@/components/dashboard/recent-drops-table"; +import { Header } from "@/components/header"; +import { + actionItems, + attendanceThisWeek, + recentDrops, + upcomingMeets, +} from "@/lib/mock-data"; + +interface HomePageProps { + params: Promise<{ teamId: string }>; +} + +const urgencyConfig = { + urgent: { label: "Urgent", variant: "destructive" as const }, + soon: { label: "Soon", variant: "secondary" as const }, + ok: { label: "On track", variant: "outline" as const }, +}; + +const actionUrgencyConfig = { + high: { + icon: XCircleIcon, + className: "text-destructive", + badgeVariant: "destructive" as const, + }, + medium: { + icon: AlertCircleIcon, + className: "text-yellow-500", + badgeVariant: "secondary" as const, + }, + low: { + icon: CheckCircle2Icon, + className: "text-muted-foreground", + badgeVariant: "outline" as const, + }, +}; + +const actionTypeIcon = { + entry_deadline: CalendarIcon, + missing_times: ClockIcon, + import_needed: UploadIcon, + attendance: UsersIcon, +}; + +export async function generateMetadata( + { params }: HomePageProps, + parent: ResolvingMetadata +): Promise { + const { teamId } = await params; + const previousMetadata = (await parent).title; + + return { + ...previousMetadata, + alternates: { + canonical: `/team/${teamId}`, + }, + }; +} + +export default async function AdminHomePage({ + params, +}: { + params: Promise<{ teamId: string }>; +}) { + const { teamId } = await params; + const totalAthletes = 24; + const activeMeets = upcomingMeets.length; + const pbsThisMonth = recentDrops.length; + const avgAttendance = Math.round( + (attendanceThisWeek.reduce((sum, d) => sum + d.present, 0) / + attendanceThisWeek.reduce((sum, d) => sum + d.total, 0)) * + 100 + ); + + return ( + <> +
+
+
+
+

Dashboard

+

+ Sunday, March 15, 2026 +

+
+
+ + +
+
+ {/* Stat cards */} +
+ + + + + Athletes + + + +
+ {totalAthletes} +
+

+ Active on roster +

+
+
+ + + + + + Upcoming meets + + + +
+ {activeMeets} +
+

Next 30 days

+
+
+ + + + + + PBs this month + + + +
+ {pbsThisMonth} +
+

+ Personal bests set +

+
+
+ + + + + + Avg attendance + + + +
+ {avgAttendance}% +
+

This week

+
+
+
+ {/* Action items + Upcoming meets */} +
+ {/* Action items */} + + +
+ Needs attention + + {actionItems.filter((a) => a.urgency === "high").length}{" "} + urgent + +
+
+ +
+ {actionItems.map((item) => { + const { icon: UrgencyIcon, className } = + actionUrgencyConfig[item.urgency]; + const TypeIcon = actionTypeIcon[item.type]; + return ( + + +
+

+ {item.title} +

+

+ {item.description} +

+
+ + + ); + })} +
+
+
+ + {/* Upcoming meets */} + + +
+ Upcoming meets + +
+
+ +
+ {upcomingMeets.map((meet) => { + const { label, variant } = + urgencyConfig[meet.deadlineUrgency]; + const entryPct = Math.round( + (meet.entriesSubmitted / meet.entriesTotal) * 100 + ); + return ( + +
+
+

+ {meet.name} +

+

+ {meet.date} · {meet.location} +

+
+ + {label} + +
+
+
+ + Entries: {meet.entriesSubmitted}/{meet.entriesTotal} + + + Due {meet.entryDeadline} + +
+ +
+ + ); + })} +
+
+
+
+ + {/* Recent PBs + Attendance */} +
+ {/* Recent PBs table — takes 2/3 width */} + + +
+
+ + Recent personal bests + + + Times dropped in the last 7 days + +
+ +
+
+ + + +
+ + {/* Attendance this week — takes 1/3 width */} + + + Attendance this week + + {avgAttendance}% average turnout + + + +
+ {attendanceThisWeek.map((day) => { + const pct = Math.round((day.present / day.total) * 100); + return ( +
+
+ + {day.date} + + + {day.present}/{day.total} + + + {pct}% + +
+ +
+ ); + })} +
+ + +
+
+
+
+ + ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/athletes/page.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/athletes/page.tsx new file mode 100644 index 0000000..772a897 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/athletes/page.tsx @@ -0,0 +1,108 @@ +import { getAthletesByPublicId } from "@project-aqua/database/queries/athlete"; +import { getTeamByPublicId } from "@project-aqua/database/queries/team"; +import { + Button, + buttonVariants, +} from "@project-aqua/design-system/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@project-aqua/design-system/components/ui/empty"; +import { + ArrowUpRightIcon, + UploadIcon, + UserPlusIcon, + UsersIcon, +} from "lucide-react"; +import Link from "next/link"; +import { Header } from "@/components/header"; +import { AthletesTable } from "@/components/roster/athletes/table"; + +interface AthletesPageProps { + params: Promise<{ teamId: string }>; +} + +export default async function AthletesPage({ params }: AthletesPageProps) { + const { teamId } = await params; + const [team, athletes] = await Promise.all([ + getTeamByPublicId(teamId), + getAthletesByPublicId(teamId), + ]); + + return ( + <> +
+ +
+ {/* Page header */} +
+
+

Athletes

+
+ {athletes?.length > 0 && ( +
+ + +
+ )} +
+ + {athletes?.length > 0 ? ( + + ) : ( + + + + + + No Athletes Yet + + You haven't added any athletes yet. Get started by adding + your first athlete. + + + + + Add Athlete + + + Import Roster + + + + + )} +
+ + ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/coaches/page.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/coaches/page.tsx new file mode 100644 index 0000000..596c5a1 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/coaches/page.tsx @@ -0,0 +1,79 @@ +import { Button } from "@project-aqua/design-system/components/ui/button"; +import { AlertCircleIcon, ClockIcon, UserPlusIcon } from "lucide-react"; +import Link from "next/link"; +import { Header } from "@/components/header"; +import { CoachesTable } from "@/components/roster/coaches-table"; +import { MOCK_COACHES } from "@/lib/mock-data"; + +interface CoachesPageProps { + params: Promise<{ teamId: string }>; +} + +export default async function CoachesPage({ params }: CoachesPageProps) { + const { teamId } = await params; + + // TODO: Replace with Supabase query + const coaches = MOCK_COACHES; + + const expiredCerts = coaches.filter((c) => c.certStatus === "expired"); + const expiringSoon = coaches.filter((c) => c.certStatus === "expiring_soon"); + + return ( + <> +
+ +
+
+
+

+ Coaches & staff +

+

+ {coaches.length} staff member{coaches.length === 1 ? "" : "s"} +

+
+ +
+ + {/* Cert alerts */} + {expiredCerts.length > 0 && ( +
+ +
+

+ {expiredCerts.length} coach + {expiredCerts.length === 1 ? " has" : "es have"} an expired USA + Swimming certification +

+

+ {expiredCerts.map((c) => c.displayName).join(", ")} +

+
+
+ )} + + {expiringSoon.length > 0 && ( +
+ +
+

+ {expiringSoon.length} certification + {expiringSoon.length === 1 ? " expires" : "s expire"} soon +

+

+ {expiringSoon.map((c) => c.displayName).join(", ")} +

+
+
+ )} + + +
+ + ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/cl2-results.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/cl2-results.tsx new file mode 100644 index 0000000..8bc3390 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/cl2-results.tsx @@ -0,0 +1,169 @@ +"use client"; +import { Badge } from "@project-aqua/design-system/components/ui/badge"; +import { Input } from "@project-aqua/design-system/components/ui/input"; +import { ScrollArea } from "@project-aqua/design-system/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@project-aqua/design-system/components/ui/select"; +import { Separator } from "@project-aqua/design-system/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@project-aqua/design-system/components/ui/table"; +import { + type Cl2EntriesFile, + type Cl2ResultsFile, + groupCl2ByAthlete, +} from "@project-aqua/parsers/cl2"; +import { ageGroupLabel } from "@project-aqua/parsers/utils"; +import { useState } from "react"; +import { GenderCell } from "./gender-cell"; +import { GenderSelect } from "./gender-select"; +import { MetricTile } from "./metric-tile"; +import { RoundBadge } from "./round-badge"; +import { RowCount } from "./row-count"; +import { SplitPills } from "./split-pills"; +import { StrokeBadge } from "./stroke-badge"; +import { StrokeSelect } from "./stroke-select"; +import { TimeCell } from "./time-cell"; + +export function Cl2ResultsView({ + data, +}: { + data: Cl2ResultsFile | Cl2EntriesFile; +}) { + const [nameFilter, setNameFilter] = useState(""); + const [genderFilter, setGenderFilter] = useState("all"); + const [strokeFilter, setStrokeFilter] = useState("all"); + const [roundFilter, setRoundFilter] = useState("all"); + const byAthlete = groupCl2ByAthlete(data); + + const filtered = data.results.filter((r) => { + if ( + nameFilter && + !`${r.lastName} ${r.firstName}` + .toLowerCase() + .includes(nameFilter.toLowerCase()) + ) { + return false; + } + if (genderFilter !== "all" && r.gender !== genderFilter) { + return false; + } + if (strokeFilter !== "all" && r.stroke !== strokeFilter) { + return false; + } + if (roundFilter !== "all" && r.round !== roundFilter) { + return false; + } + return true; + }); + + return ( +
+
+ + + + + + + + +
+ +
+ setNameFilter(e.target.value)} + placeholder="Filter by name..." + value={nameFilter} + /> + + + + +
+ +
+ + + Athlete + G + Age + Dist + Stroke + Age Group + Rnd + Heat + Lane + Place + Time + Splits + DQ + + + + {filtered.map((r, i) => ( + + + {r.lastName}, {r.firstName} + + + + + {r.age} + {r.distance} + + + + + {ageGroupLabel(r.ageGroupMin, r.ageGroupMax)} + + + + + {r.heat || "—"} + {r.lane || "—"} + {r.heatPlace || "—"} + + + + + + + + {r.dqCode && ( + + DQ {r.dqCode} + + )} + + + ))} + +
+ +
+ ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/cl2-view.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/cl2-view.tsx new file mode 100644 index 0000000..db65a21 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/cl2-view.tsx @@ -0,0 +1,18 @@ +import type { ParsedCl2File } from "@project-aqua/parsers/cl2"; +import { Cl2ResultsView } from "./cl2-results"; +import { Cl2RosterView } from "./cls-roster"; + +export function Cl2View({ data }: { data: ParsedCl2File }) { + if (data.fileType === "roster") { + return ; + } + if (data.fileType === "results" || data.fileType === "entries") { + return ; + } + return ( +
+ Unknown CL2 file type (fileCode: {data.fileCode}). Raw lines:{" "} + {data.rawLines.length} +
+ ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/cls-roster.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/cls-roster.tsx new file mode 100644 index 0000000..ddf26db --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/cls-roster.tsx @@ -0,0 +1,122 @@ +"use client"; +import { Badge } from "@project-aqua/design-system/components/ui/badge"; +import { Input } from "@project-aqua/design-system/components/ui/input"; +import { ScrollArea } from "@project-aqua/design-system/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@project-aqua/design-system/components/ui/select"; +import { Separator } from "@project-aqua/design-system/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@project-aqua/design-system/components/ui/table"; +import type { Cl2RosterFile } from "@project-aqua/parsers/cl2"; +import type { RosterAthlete } from "@project-aqua/parsers/types"; +import { useState } from "react"; +import { GenderCell } from "./gender-cell"; +import { GenderSelect } from "./gender-select"; +import { MetricTile } from "./metric-tile"; +import { RowCount } from "./row-count"; + +export function Cl2RosterView({ data }: { data: Cl2RosterFile }) { + const [nameFilter, setNameFilter] = useState(""); + const [genderFilter, setGenderFilter] = useState("all"); + const [gradeFilter, setGradeFilter] = useState("all"); + + const filtered = data.athletes.filter((a) => { + if ( + nameFilter && + !`${a.lastName} ${a.firstName}` + .toLowerCase() + .includes(nameFilter.toLowerCase()) + ) { + return false; + } + if (genderFilter !== "all" && a.gender !== genderFilter) { + return false; + } + if (gradeFilter !== "all" && a.gradeYear !== gradeFilter) { + return false; + } + return true; + }); + + return ( +
+
+ + + + +
+ +
+ setNameFilter(e.target.value)} + placeholder="Filter by name..." + value={nameFilter} + /> + + + +
+ + + + + Athlete + Gender + Grade + LSC + + + + {filtered.map((a: RosterAthlete, i: number) => ( + + + {a.lastName}, {a.firstName} + + + + + + {a.gradeYear && ( + + {a.gradeYear} + + )} + + + {a.lsc} + + + ))} + +
+
+
+ ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/course-badge.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/course-badge.tsx new file mode 100644 index 0000000..c14670d --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/course-badge.tsx @@ -0,0 +1,18 @@ +import { Badge } from "@project-aqua/design-system/components/ui/badge"; +import { cn } from "@project-aqua/design-system/lib/utils"; + +export function CourseBadge({ course }: { course: string }) { + const colors: Record = { + Y: "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300", + S: "bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300", + L: "bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300", + }; + return ( + + {course === "Y" ? "SCY" : course === "S" ? "SCM" : "LCM"} + + ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/dropzone.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/dropzone.tsx new file mode 100644 index 0000000..7955a00 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/dropzone.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { cn } from "@project-aqua/design-system/lib/utils"; +import { useDropzone } from "react-dropzone"; + +const ACCEPTED_EXTENSIONS = [".sd3", ".hy3", ".cl2", ".hyv", ".ev3"]; + +export function DropZone({ onFiles }: { onFiles: (files: File[]) => void }) { + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop: (accepted) => onFiles(accepted), + // react-dropzone needs custom validator for non-MIME types like .sd3 + validator: (file) => { + const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; + if (!ACCEPTED_EXTENSIONS.includes(`.${ext}`)) { + return { + code: "file-invalid-type", + message: `Unsupported type: .${ext}`, + }; + } + return null; + }, + }); + + return ( +
+ +
+ +
+

+ {isDragActive ? "Drop files here" : "Drop Hytek files here"} +

+

+ .sd3 .hy3 .cl2 .hyv .ev3 — single or multiple files +

+
+ ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/ev3-view.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/ev3-view.tsx new file mode 100644 index 0000000..e020b01 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/ev3-view.tsx @@ -0,0 +1,127 @@ +"use client"; +import { ScrollArea } from "@project-aqua/design-system/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@project-aqua/design-system/components/ui/select"; +import { Separator } from "@project-aqua/design-system/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@project-aqua/design-system/components/ui/table"; +import type { Ev3File } from "@project-aqua/parsers/types"; +import { ageGroupLabel } from "@project-aqua/parsers/utils"; +import { useState } from "react"; +import { GenderCell } from "./gender-cell"; +import { GenderSelect } from "./gender-select"; +import { MetricTile } from "./metric-tile"; +import { RoundBadge } from "./round-badge"; +import { RowCount } from "./row-count"; +import { StrokeBadge } from "./stroke-badge"; +import { StrokeSelect } from "./stroke-select"; + +export function Ev3View({ data }: { data: Ev3File }) { + const [genderFilter, setGenderFilter] = useState("all"); + const [strokeFilter, setStrokeFilter] = useState("all"); + const [sessionFilter, setSessionFilter] = useState("all"); + + const sessions = [...new Set(data.events.map((e) => e.session))].sort( + (a, b) => a - b + ); + + const filtered = data.events.filter((e) => { + if (genderFilter !== "all" && e.gender !== genderFilter) { + return false; + } + if (strokeFilter !== "all" && e.stroke !== strokeFilter) { + return false; + } + if (sessionFilter !== "all" && String(e.session) !== sessionFilter) { + return false; + } + return true; + }); + + return ( +
+
+ + + + + + +
+ +
+ + + + +
+ + + + + Code + # + Gender + Dist + Stroke + Age Group + Round + Session + Start + Lanes + + + + {filtered.map((e, i) => ( + + + {e.eventCode} + + + {e.eventNumber} + + + + + {e.distance} + + + + {ageGroupLabel(e.ageMin, e.ageMax)} + + + + {e.session} + {e.startTime || "—"} + {e.laneCount || "—"} + + ))} + +
+
+
+ ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/file-type-badge.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/file-type-badge.tsx new file mode 100644 index 0000000..a45500d --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/file-type-badge.tsx @@ -0,0 +1,23 @@ +import { Badge } from "@project-aqua/design-system/components/ui/badge"; +import { cn } from "@project-aqua/design-system/lib/utils"; +import type { FileType } from "../page"; + +const FILE_TYPE_COLORS: Record = { + sd3: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", + hy3: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200", + cl2: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200", + hyv: "bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200", + ev3: "bg-rose-100 text-rose-800 dark:bg-rose-900 dark:text-rose-200", + unknown: "bg-muted text-muted-foreground", +}; + +export function FileTypeBadge({ type }: { type: FileType }) { + return ( + + {type} + + ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/gender-cell.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/gender-cell.tsx new file mode 100644 index 0000000..ac27100 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/gender-cell.tsx @@ -0,0 +1,14 @@ +import { cn } from "@project-aqua/design-system/lib/utils"; + +export function GenderCell({ gender }: { gender: string }) { + return ( + + {gender} + + ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/gender-select.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/gender-select.tsx new file mode 100644 index 0000000..b2ee975 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/gender-select.tsx @@ -0,0 +1,28 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@project-aqua/design-system/components/ui/select"; + +export function GenderSelect({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + return ( + + ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/hy3-results.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/hy3-results.tsx new file mode 100644 index 0000000..93c7ab0 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/hy3-results.tsx @@ -0,0 +1,200 @@ +"use client"; +import { Badge } from "@project-aqua/design-system/components/ui/badge"; +import { Input } from "@project-aqua/design-system/components/ui/input"; +import { ScrollArea } from "@project-aqua/design-system/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@project-aqua/design-system/components/ui/select"; +import { Separator } from "@project-aqua/design-system/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@project-aqua/design-system/components/ui/table"; +import { + flattenHy3Results, + type Hy3EntriesFile, + type Hy3FlatResult, + type Hy3ResultsFile, +} from "@project-aqua/parsers/hy3"; +import { ageGroupLabel } from "@project-aqua/parsers/utils"; +import { useState } from "react"; +import { CourseBadge } from "./course-badge"; +import { GenderCell } from "./gender-cell"; +import { GenderSelect } from "./gender-select"; +import { MetricTile } from "./metric-tile"; +import { RowCount } from "./row-count"; +import { SplitPills } from "./split-pills"; +import { StrokeBadge } from "./stroke-badge"; +import { StrokeSelect } from "./stroke-select"; +import { TimeCell } from "./time-cell"; + +export function Hy3ResultsView({ + data, +}: { + data: Hy3ResultsFile | Hy3EntriesFile; +}) { + const [nameFilter, setNameFilter] = useState(""); + const [teamFilter, setTeamFilter] = useState("all"); + const [genderFilter, setGenderFilter] = useState("all"); + const [strokeFilter, setStrokeFilter] = useState("all"); + + const flat = flattenHy3Results(data); + const teams = [...data.teams.keys()].sort(); + let totalAthletes = 0; + for (const { athletes } of data.teams.values()) { + totalAthletes += athletes.length; + } + + const filtered = flat.filter((r: Hy3FlatResult) => { + if ( + nameFilter && + !`${r.athlete.lastName} ${r.athlete.firstName}` + .toLowerCase() + .includes(nameFilter.toLowerCase()) + ) { + return false; + } + if (teamFilter !== "all" && r.teamAbbr !== teamFilter) { + return false; + } + if (genderFilter !== "all" && r.athlete.gender !== genderFilter) { + return false; + } + if (strokeFilter !== "all" && r.entry.stroke !== strokeFilter) { + return false; + } + return true; + }); + + return ( +
+
+ + + + + + + + +
+ +
+ setNameFilter(e.target.value)} + placeholder="Filter by name..." + value={nameFilter} + /> + + + + +
+ + + + + Athlete + Team + G + Age + Dist + Stroke + Age Group + Entry Time + Result + Heat + Lane + Place + Splits + DQ + + + + {filtered.map((r: Hy3FlatResult, i: number) => { + const best = + r.results.find((x) => x.round === "F") ?? r.results[0]; + return ( + + + {r.athlete.lastName}, {r.athlete.firstName} + + + {r.teamAbbr} + + + + + {r.athlete.age} + {r.entry.distance} + + + + + {ageGroupLabel(r.entry.ageGroupMin, r.entry.ageGroupMax)} + + +
+ + {r.entry.qualifyingTime && ( + + )} +
+
+ + {best ? ( +
+ + {best.finishTime && ( + + )} +
+ ) : ( + NS + )} +
+ {best?.heat || "—"} + {best?.lane || "—"} + + {best?.heatPlace || "—"} + + + + + + {best?.dqCode && ( + + DQ {best.dqCode} + + )} + +
+ ); + })} +
+
+
+
+ ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/hy3-roster.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/hy3-roster.tsx new file mode 100644 index 0000000..0ae1b80 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/hy3-roster.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { Badge } from "@project-aqua/design-system/components/ui/badge"; +import { Input } from "@project-aqua/design-system/components/ui/input"; +import { ScrollArea } from "@project-aqua/design-system/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@project-aqua/design-system/components/ui/select"; +import { Separator } from "@project-aqua/design-system/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@project-aqua/design-system/components/ui/table"; +import type { Hy3RosterFile } from "@project-aqua/parsers/hy3"; +import type { RosterAthlete } from "@project-aqua/parsers/types"; +import { useState } from "react"; +import { GenderCell } from "./gender-cell"; +import { GenderSelect } from "./gender-select"; +import { MetricTile } from "./metric-tile"; +import { RowCount } from "./row-count"; + +export function Hy3RosterView({ data }: { data: Hy3RosterFile }) { + const [nameFilter, setNameFilter] = useState(""); + const [genderFilter, setGenderFilter] = useState("all"); + const [gradeFilter, setGradeFilter] = useState("all"); + + const filtered = data.athletes.filter((a) => { + if ( + nameFilter && + !`${a.lastName} ${a.firstName}` + .toLowerCase() + .includes(nameFilter.toLowerCase()) + ) { + return false; + } + if (genderFilter !== "all" && a.gender !== genderFilter) { + return false; + } + if (gradeFilter !== "all" && a.gradeYear !== gradeFilter) { + return false; + } + return true; + }); + + return ( +
+
+ + + + + + +
+ {data.team.email && ( +

+ Email: {data.team.email} +

+ )} + +
+ setNameFilter(e.target.value)} + placeholder="Filter by name..." + value={nameFilter} + /> + + + +
+ + + + + # + Athlete + Gender + Grade + + + + {filtered.map((a: RosterAthlete) => ( + + + {a.jerseyNumber || "—"} + + + {a.lastName}, {a.firstName} + + + + + + {a.gradeYear && ( + + {a.gradeYear} + + )} + + + ))} + +
+
+
+ ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/hy3-view.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/hy3-view.tsx new file mode 100644 index 0000000..fbd16c3 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/hy3-view.tsx @@ -0,0 +1,18 @@ +import type { ParsedHy3File } from "@project-aqua/parsers/hy3"; +import { Hy3ResultsView } from "./hy3-results"; +import { Hy3RosterView } from "./hy3-roster"; + +export function Hy3View({ data }: { data: ParsedHy3File }) { + if (data.fileType === "roster") { + return ; + } + if (data.fileType === "results" || data.fileType === "entries") { + return ; + } + return ( +
+ Unknown HY3 file type (fileCode: {data.fileCode}). Raw lines:{" "} + {data.rawLines.length} +
+ ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/hyv-view.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/hyv-view.tsx new file mode 100644 index 0000000..2c26ea8 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/hyv-view.tsx @@ -0,0 +1,102 @@ +"use client"; +import { ScrollArea } from "@project-aqua/design-system/components/ui/scroll-area"; +import { Separator } from "@project-aqua/design-system/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@project-aqua/design-system/components/ui/table"; +import type { HyvFile } from "@project-aqua/parsers/types"; +import { ageGroupLabel } from "@project-aqua/parsers/utils"; +import { useState } from "react"; +import { GenderCell } from "./gender-cell"; +import { GenderSelect } from "./gender-select"; +import { MetricTile } from "./metric-tile"; +import { RoundBadge } from "./round-badge"; +import { RowCount } from "./row-count"; +import { StrokeBadge } from "./stroke-badge"; +import { StrokeSelect } from "./stroke-select"; +import { TimeCell } from "./time-cell"; + +export function HyvView({ data }: { data: HyvFile }) { + const [genderFilter, setGenderFilter] = useState("all"); + const [strokeFilter, setStrokeFilter] = useState("all"); + + const filtered = data.events.filter((e) => { + if (genderFilter !== "all" && e.gender !== genderFilter) { + return false; + } + if (strokeFilter !== "all" && e.stroke !== strokeFilter) { + return false; + } + return true; + }); + + return ( +
+
+ + + + + + +
+ +
+ + + +
+ + + + + Code + Gender + Dist + Stroke + Age Group + Round + A-Cut + B-Cut + Fee + + + + {filtered.map((e, i) => ( + + + {e.eventCode} + + + + + {e.distance} + + + + {ageGroupLabel(e.ageMin, e.ageMax)} + + + + + + + + + + + {e.entryFee > 0 ? `$${e.entryFee.toFixed(2)}` : "—"} + + + ))} + +
+
+
+ ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/metric-tile.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/metric-tile.tsx new file mode 100644 index 0000000..c040a8d --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/metric-tile.tsx @@ -0,0 +1,18 @@ +export function MetricTile({ + label, + value, +}: { + label: string; + value: string | number; +}) { + return ( +
+

+ {label} +

+

+ {value || "—"} +

+
+ ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/parsed-file-card.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/parsed-file-card.tsx new file mode 100644 index 0000000..ec2e4cf --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/parsed-file-card.tsx @@ -0,0 +1,176 @@ +import { + Alert, + AlertDescription, +} from "@project-aqua/design-system/components/ui/alert"; +import { Badge } from "@project-aqua/design-system/components/ui/badge"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@project-aqua/design-system/components/ui/card"; +import { ScrollArea } from "@project-aqua/design-system/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@project-aqua/design-system/components/ui/table"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@project-aqua/design-system/components/ui/tabs"; +import { cn } from "@project-aqua/design-system/lib/utils"; +import type { ParsedCl2File } from "@project-aqua/parsers/cl2"; +import type { ParsedHy3File } from "@project-aqua/parsers/hy3"; +import type { Ev3File, HyvFile, Sd3File } from "@project-aqua/parsers/types"; +import type { FileType, ParsedFile } from "../page"; +import { Cl2View } from "./cl2-view"; +import { Ev3View } from "./ev3-view"; +import { Hy3ResultsView } from "./hy3-results"; +import { Hy3View } from "./hy3-view"; +import { HyvView } from "./hyv-view"; +import { Sd3View } from "./sd3-view"; + +const FILE_TYPE_COLORS: Record = { + sd3: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", + hy3: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200", + cl2: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200", + hyv: "bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200", + ev3: "bg-rose-100 text-rose-800 dark:bg-rose-900 dark:text-rose-200", + unknown: "bg-muted text-muted-foreground", +}; + +function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + return `${(bytes / 1024).toFixed(1)} KB`; +} + +function FileTypeBadge({ type }: { type: FileType }) { + return ( + + {type} + + ); +} + +export function ParsedFileCard({ file }: { file: ParsedFile }) { + if (file.error) { + return ( + + +
+ + {file.name} +
+
+ + + + {file.error} + + + +
+ ); + } + + if (!file.data) { + return null; + } + + return ( + + +
+
+ + {file.name} +
+ + {formatBytes(file.size)} + +
+
+ + {file.type === "sd3" && } + {file.type === "hyv" && } + {file.type === "ev3" && } + {file.type === "cl2" && } + {file.type === "hy3" && + (() => { + const hy3data = file.data as ParsedHy3File; + // Results/entries get a Teams tab; roster is self-contained + if ( + hy3data.fileType === "results" || + hy3data.fileType === "entries" + ) { + return ( + + + Results + + Teams ({hy3data.teams.size}) + + + + + + + + + + + Abbr + Name + LSC + City + Coach + Athletes + + + + {[...hy3data.teams.entries()].map( + ([abbr, { team, athletes }]) => ( + + + {abbr} + + {team.name} + + {team.lsc} + + + {[team.city, team.state] + .filter(Boolean) + .join(", ")} + + + {team.coachName} + + {athletes.length} + + ) + )} + +
+
+
+
+ ); + } + return ; + })()} +
+
+ ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/round-badge.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/round-badge.tsx new file mode 100644 index 0000000..f82d494 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/round-badge.tsx @@ -0,0 +1,18 @@ +import { Badge } from "@project-aqua/design-system/components/ui/badge"; +import { cn } from "@project-aqua/design-system/lib/utils"; + +export function RoundBadge({ round }: { round: string }) { + return ( + + {round === "F" ? "Finals" : round === "P" ? "Prelims" : round} + + ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/row-count.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/row-count.tsx new file mode 100644 index 0000000..cee2b64 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/row-count.tsx @@ -0,0 +1,13 @@ +export function RowCount({ + filtered, + total, +}: { + filtered: number; + total: number; +}) { + return ( + + {filtered === total ? `${total} rows` : `${filtered} / ${total} rows`} + + ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/sd3-view.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/sd3-view.tsx new file mode 100644 index 0000000..03edef8 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/sd3-view.tsx @@ -0,0 +1,129 @@ +"use client"; +import { Input } from "@project-aqua/design-system/components/ui/input"; +import { ScrollArea } from "@project-aqua/design-system/components/ui/scroll-area"; +import { Separator } from "@project-aqua/design-system/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@project-aqua/design-system/components/ui/table"; +import { groupSd3ByAthlete } from "@project-aqua/parsers/sd3"; +import type { Sd3File } from "@project-aqua/parsers/types"; +import { ageGroupLabel } from "@project-aqua/parsers/utils"; +import { useState } from "react"; +import { CourseBadge } from "./course-badge"; +import { GenderCell } from "./gender-cell"; +import { GenderSelect } from "./gender-select"; +import { MetricTile } from "./metric-tile"; +import { RowCount } from "./row-count"; +import { StrokeBadge } from "./stroke-badge"; +import { StrokeSelect } from "./stroke-select"; +import { TimeCell } from "./time-cell"; + +export function Sd3View({ data }: { data: Sd3File }) { + const [nameFilter, setNameFilter] = useState(""); + const [genderFilter, setGenderFilter] = useState("all"); + const [strokeFilter, setStrokeFilter] = useState("all"); + const athletes = groupSd3ByAthlete(data); + + const filtered = data.entries.filter((e) => { + if ( + nameFilter && + !`${e.lastName} ${e.firstName}` + .toLowerCase() + .includes(nameFilter.toLowerCase()) + ) { + return false; + } + if (genderFilter !== "all" && e.gender !== genderFilter) { + return false; + } + if (strokeFilter !== "all" && e.stroke !== strokeFilter) { + return false; + } + return true; + }); + + return ( +
+
+ + + + + + +
+ +
+ setNameFilter(e.target.value)} + placeholder="Filter by name..." + value={nameFilter} + /> + + + +
+ + + + + Athlete + G + Age + DOB + Dist + Stroke + Age Group + Seed Time + Member ID + + + + {filtered.map((e, i) => ( + + + {e.lastName}, {e.firstName} + + + + + {e.age} + + {e.dob} + + {e.distance} + + + + + {ageGroupLabel(e.ageGroupMin, e.ageGroupMax)} + + +
+ + {e.seedTime && } +
+
+ + {e.memberId} + +
+ ))} +
+
+
+
+ ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/split-pills.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/split-pills.tsx new file mode 100644 index 0000000..76f0077 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/split-pills.tsx @@ -0,0 +1,19 @@ +import { formatTime } from "@project-aqua/parsers/utils"; + +export function SplitPills({ splits }: { splits: number[] }) { + if (!splits.length) { + return null; + } + return ( +
+ {splits.map((s, i) => ( + + {formatTime(s)} + + ))} +
+ ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/stroke-badge.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/stroke-badge.tsx new file mode 100644 index 0000000..7d54880 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/stroke-badge.tsx @@ -0,0 +1,10 @@ +import { Badge } from "@project-aqua/design-system/components/ui/badge"; +import { STROKE_LABELS } from "@project-aqua/parsers/types"; + +export function StrokeBadge({ stroke }: { stroke: string }) { + return ( + + {STROKE_LABELS[stroke as keyof typeof STROKE_LABELS] ?? stroke} + + ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/stroke-select.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/stroke-select.tsx new file mode 100644 index 0000000..cfb3036 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/stroke-select.tsx @@ -0,0 +1,34 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@project-aqua/design-system/components/ui/select"; +import { STROKE_LABELS } from "@project-aqua/parsers/types"; + +export function StrokeSelect({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + return ( + + ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/time-cell.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/time-cell.tsx new file mode 100644 index 0000000..e8ab764 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/_components/time-cell.tsx @@ -0,0 +1,8 @@ +import { formatTime } from "@project-aqua/parsers/utils"; + +export function TimeCell({ seconds }: { seconds: number | null }) { + if (seconds === null) { + return NT; + } + return {formatTime(seconds)}; +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/import/page.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/page.tsx new file mode 100644 index 0000000..ea1f773 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/import/page.tsx @@ -0,0 +1,129 @@ +"use client"; +import { Button } from "@project-aqua/design-system/components/ui/button"; +import { parseCl2 } from "@project-aqua/parsers/cl2"; +import { parseEv3 } from "@project-aqua/parsers/ev3"; +import { parseHy3 } from "@project-aqua/parsers/hy3"; +import { parseHyv } from "@project-aqua/parsers/hyv"; +import { parseSd3 } from "@project-aqua/parsers/sd3"; +import type { + Cl2File, + Ev3File, + Hy3File, + HyvFile, + Sd3File, +} from "@project-aqua/parsers/types"; +import { useCallback, useState } from "react"; +import { Header } from "@/components/header"; +import { DropZone } from "./_components/dropzone"; +import { ParsedFileCard } from "./_components/parsed-file-card"; + +export type FileType = "sd3" | "hy3" | "cl2" | "hyv" | "ev3" | "unknown"; + +export interface ParsedFile { + data?: Sd3File | HyvFile | Ev3File | Cl2File | Hy3File; + error?: string; + id: string; + name: string; + size: number; + type: FileType; +} + +function getFileType(name: string): FileType { + const ext = name.split(".").pop()?.toLowerCase() ?? ""; + if (["sd3", "hy3", "cl2", "hyv", "ev3"].includes(ext)) { + return ext as FileType; + } + return "unknown"; +} + +export default function ImportRosterPage() { + const [parsedFiles, setParsedFiles] = useState([]); + const [parsing, setParsing] = useState(false); + + const handleFiles = useCallback(async (incoming: File[]) => { + setParsing(true); + const results: ParsedFile[] = []; + + for (const file of incoming) { + const type = getFileType(file.name); + const id = `${file.name}-${Date.now()}`; + const buf = Buffer.from(await file.arrayBuffer()); + + try { + let data: Sd3File | HyvFile | Ev3File | Cl2File | Hy3File; + if (type === "sd3") { + data = parseSd3(buf); + } else if (type === "hyv") { + data = parseHyv(buf); + } else if (type === "ev3") { + data = parseEv3(buf); + } else if (type === "cl2") { + data = parseCl2(buf); + } else if (type === "hy3") { + data = parseHy3(buf); + } else { + throw new Error(`Unsupported file type: .${type}`); + } + + results.push({ id, name: file.name, type, size: file.size, data }); + } catch (err) { + results.push({ + id, + name: file.name, + type, + size: file.size, + error: err instanceof Error ? err.message : "Parse failed", + }); + } + } + + setParsedFiles((prev) => [...prev, ...results]); + setParsing(false); + }, []); + + return ( + <> +
+
+
+

+ Hytek File Inspector +

+

+ Upload SD3, HY3, CL2, HYV, or EV3 files to inspect their parsed + contents. +

+
+ + {parsing && ( +

+ Parsing files... +

+ )} + + {parsedFiles.length > 0 && ( +
+

+ {parsedFiles.length} file{parsedFiles.length === 1 ? "" : "s"}{" "} + loaded +

+ +
+ )} + +
+ {parsedFiles.map((file) => ( + + ))} +
+
+ + ); +} diff --git a/apps/admin/app/(authenticated)/team/[teamId]/roster/page.tsx b/apps/admin/app/(authenticated)/team/[teamId]/roster/page.tsx new file mode 100644 index 0000000..abc6dd2 --- /dev/null +++ b/apps/admin/app/(authenticated)/team/[teamId]/roster/page.tsx @@ -0,0 +1,241 @@ +// app/team/[teamId]/roster/page.tsx + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, +} from "@project-aqua/design-system/components/ui/breadcrumb"; +import { Button } from "@project-aqua/design-system/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@project-aqua/design-system/components/ui/card"; +import { Separator } from "@project-aqua/design-system/components/ui/separator"; +import { SidebarTrigger } from "@project-aqua/design-system/components/ui/sidebar"; +import { + ChevronRightIcon, + ShieldCheckIcon, + UploadIcon, + UserPlusIcon, + UsersIcon, +} from "lucide-react"; +import Link from "next/link"; +import { MOCK_GROUPS, MOCK_STATS } from "@/lib/mock-data"; + +interface RosterPageProps { + params: Promise<{ teamId: string }>; +} + +export default async function RosterPage({ params }: RosterPageProps) { + const { teamId } = await params; + const stats = MOCK_STATS; + const groups = MOCK_GROUPS; + + return ( + <> +
+ + + + + + Roster + + + +
+ +
+ {/* Page header */} +
+
+

Roster

+

+ Manage athletes, coaches, and training groups. +

+
+
+ + +
+
+ + {/* Stat cards */} +
+ + + + + Total athletes + + + +
+ {stats.totalAthletes} +
+

+ {stats.activeAthletes} active +

+
+
+ + + + Male / Female + + +
+ {stats.maleCount} + + / + + {stats.femaleCount} +
+

gender split

+
+
+ + + + + + Coaches + + + +
+ {stats.totalCoaches} +
+

on staff

+
+
+ + + + Training groups + + +
+ {stats.groupCount} +
+

+ active groups +

+
+
+
+ + {/* Quick nav cards */} +
+ +
+
+ +
+ +
+

Athletes

+

+ {stats.totalAthletes} athletes across {stats.groupCount} groups +

+ + + +
+
+ +
+ +
+

Coaches & staff

+

+ {stats.totalCoaches} staff members, certifications & roles +

+ +
+ + {/* Groups breakdown */} + + +
+
+ Training groups + + Athlete distribution by group + +
+ +
+
+ +
+ {groups.map((group) => { + const pct = Math.round( + (group.athleteCount / stats.totalAthletes) * 100 + ); + return ( +
+
+

{group.name}

+

+ {group.coach} +

+
+
+
+
+
+
+ {group.maleCount}M + + {group.femaleCount}F + +
+
+
+

+ {group.athleteCount} +

+

athletes

+
+
+ ); + })} +
+ + +
+ + ); +} diff --git a/apps/admin/app/team/[teamId]/swimmers/[swimmerId]/edit/page.tsx b/apps/admin/app/(authenticated)/team/[teamId]/swimmers/[swimmerId]/edit/page.tsx similarity index 80% rename from apps/admin/app/team/[teamId]/swimmers/[swimmerId]/edit/page.tsx rename to apps/admin/app/(authenticated)/team/[teamId]/swimmers/[swimmerId]/edit/page.tsx index bb335aa..774b84b 100644 --- a/apps/admin/app/team/[teamId]/swimmers/[swimmerId]/edit/page.tsx +++ b/apps/admin/app/(authenticated)/team/[teamId]/swimmers/[swimmerId]/edit/page.tsx @@ -4,7 +4,7 @@ export default async function EditAthlete({ params: Promise<{ teamId: string; athleteId: string }>; }) { const { teamId, athleteId } = await params; - console.log({ teamId: teamId, athleteId: athleteId }); + console.log({ teamId, athleteId }); return (

Edit Athlete

diff --git a/apps/admin/app/team/[teamId]/swimmers/[swimmerId]/page.tsx b/apps/admin/app/(authenticated)/team/[teamId]/swimmers/[swimmerId]/page.tsx similarity index 84% rename from apps/admin/app/team/[teamId]/swimmers/[swimmerId]/page.tsx rename to apps/admin/app/(authenticated)/team/[teamId]/swimmers/[swimmerId]/page.tsx index 3f2eb10..2f5bf85 100644 --- a/apps/admin/app/team/[teamId]/swimmers/[swimmerId]/page.tsx +++ b/apps/admin/app/(authenticated)/team/[teamId]/swimmers/[swimmerId]/page.tsx @@ -1,69 +1,51 @@ -import Link from "next/link"; -import { - User, - Calendar, - Phone, - Users, - Trophy, - TrendingUp, - Edit, -} from "lucide-react"; import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@project-aqua/ui/components/tabs"; + Avatar, + AvatarFallback, + AvatarImage, +} from "@project-aqua/design-system/components/avatar"; +import { buttonVariants } from "@project-aqua/design-system/components/button"; import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, -} from "@project-aqua/ui/components/card"; +} from "@project-aqua/design-system/components/card"; +import { Label } from "@project-aqua/design-system/components/label"; import { - Avatar, - AvatarFallback, - AvatarImage, -} from "@project-aqua/ui/components/avatar"; -import { Button, buttonVariants } from "@project-aqua/ui/components/button"; -import { Input } from "@project-aqua/ui/components/input"; -import { Label } from "@project-aqua/ui/components/label"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@project-aqua/ui/components/table"; + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@project-aqua/design-system/components/tabs"; +import { Edit } from "lucide-react"; +import Link from "next/link"; interface Swimmer { - id: string; - name: string; avatar: string; birthday: string; - joinDate: string; - group: string; emergencyContacts: { name: string; relation: string; phone: string; }[]; + group: string; + id: string; + joinDate: string; + name: string; } interface MeetResult { - id: string; date: string; - meetName: string; event: string; - time: string; + id: string; + meetName: string; place: number; + time: string; } async function getSwimmerData( - swimmerId: string, + swimmerId: string ): Promise<{ swimmer: Swimmer; meetResults: MeetResult[] }> { // In a real application, this would be an API call or database query const swimmer: Swimmer = { @@ -118,10 +100,10 @@ export default async function AthletePage({ const { swimmer, meetResults } = await getSwimmerData(swimmerId); return (
-
+
- + {swimmer.name .split(" ") @@ -130,13 +112,13 @@ export default async function AthletePage({
-

{swimmer.name}

-

{swimmer.group}

+

{swimmer.name}

+

{swimmer.group}

Edit Swimmer @@ -148,7 +130,7 @@ export default async function AthletePage({ Info Meet Results - + Swimmer Info @@ -189,7 +171,7 @@ export default async function AthletePage({
-

Primary Contact

+

Primary Contact

@@ -210,7 +192,7 @@ export default async function AthletePage({
-

Secondary Contact

+

Secondary Contact

diff --git a/apps/admin/app/team/[teamId]/swimmers/create/page.tsx b/apps/admin/app/(authenticated)/team/[teamId]/swimmers/create/page.tsx similarity index 100% rename from apps/admin/app/team/[teamId]/swimmers/create/page.tsx rename to apps/admin/app/(authenticated)/team/[teamId]/swimmers/create/page.tsx diff --git a/apps/admin/app/team/[teamId]/workouts/[workoutId]/edit/page.tsx b/apps/admin/app/(authenticated)/team/[teamId]/workouts/[workoutId]/edit/page.tsx similarity index 100% rename from apps/admin/app/team/[teamId]/workouts/[workoutId]/edit/page.tsx rename to apps/admin/app/(authenticated)/team/[teamId]/workouts/[workoutId]/edit/page.tsx diff --git a/apps/admin/app/team/[teamId]/workouts/[workoutId]/page.tsx b/apps/admin/app/(authenticated)/team/[teamId]/workouts/[workoutId]/page.tsx similarity index 100% rename from apps/admin/app/team/[teamId]/workouts/[workoutId]/page.tsx rename to apps/admin/app/(authenticated)/team/[teamId]/workouts/[workoutId]/page.tsx diff --git a/apps/admin/app/team/[teamId]/workouts/create/page.tsx b/apps/admin/app/(authenticated)/team/[teamId]/workouts/create/page.tsx similarity index 100% rename from apps/admin/app/team/[teamId]/workouts/create/page.tsx rename to apps/admin/app/(authenticated)/team/[teamId]/workouts/create/page.tsx diff --git a/apps/admin/app/team/[teamId]/workouts/page.tsx b/apps/admin/app/(authenticated)/team/[teamId]/workouts/page.tsx similarity index 100% rename from apps/admin/app/team/[teamId]/workouts/page.tsx rename to apps/admin/app/(authenticated)/team/[teamId]/workouts/page.tsx diff --git a/apps/admin/app/(unauthenticated)/sign-in/page.tsx b/apps/admin/app/(unauthenticated)/sign-in/page.tsx new file mode 100644 index 0000000..2ccecd0 --- /dev/null +++ b/apps/admin/app/(unauthenticated)/sign-in/page.tsx @@ -0,0 +1,34 @@ +import { SignInForm } from "@project-aqua/auth/components/sign-in"; +import { GalleryVerticalEnd } from "lucide-react"; +import Image from "next/image"; + +export default function LoginPage() { + return ( +
+
+ +
+
+ +
+
+
+
+ Image +
+
+ ); +} diff --git a/apps/admin/app/(unauthenticated)/sign-up/page.tsx b/apps/admin/app/(unauthenticated)/sign-up/page.tsx new file mode 100644 index 0000000..7b3c828 --- /dev/null +++ b/apps/admin/app/(unauthenticated)/sign-up/page.tsx @@ -0,0 +1,43 @@ +"use client"; +import { SignUpForm } from "@project-aqua/auth/components/sign-up"; +import { GalleryVerticalEnd } from "lucide-react"; +import Image from "next/image"; +import { useRouter, useSearchParams } from "next/navigation"; +import { getCallbackURL } from "@/lib/shared"; + +export default function SignupPage() { + const router = useRouter(); + const params = useSearchParams(); + return ( +
+
+ +
+
+ router.push(getCallbackURL(params))} + params={params} + /> +
+
+
+
+ Image +
+
+ ); +} diff --git a/apps/admin/app/api/auth/[...all]/route.ts b/apps/admin/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..a67ebbb --- /dev/null +++ b/apps/admin/app/api/auth/[...all]/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@project-aqua/auth/handlers"; diff --git a/apps/admin/app/global-error.tsx b/apps/admin/app/global-error.tsx new file mode 100644 index 0000000..5a12477 --- /dev/null +++ b/apps/admin/app/global-error.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { Button } from "@project-aqua/design-system/components/ui/button"; +import { fonts } from "@project-aqua/design-system/lib/fonts"; +import type NextError from "next/error"; + +interface GlobalErrorProperties { + readonly error: NextError & { digest?: string }; + readonly reset: () => void; +} + +const GlobalError = ({ error, reset }: GlobalErrorProperties) => { + return ( + + +

Oops, something went wrong

+ + + + ); +}; + +export default GlobalError; diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx index 9c666bd..8aa2e44 100644 --- a/apps/admin/app/layout.tsx +++ b/apps/admin/app/layout.tsx @@ -1,8 +1,11 @@ -import { GeistSans } from "geist/font/sans"; -import { GeistMono } from "geist/font/mono"; +import { Geist } from "next/font/google"; -import "@project-aqua/ui/globals.css"; -import Providers from "@/components/providers"; +import "@project-aqua/design-system/styles/globals.css"; +import { DesignSystemProvider } from "@project-aqua/design-system"; +import { fonts } from "@project-aqua/design-system/lib/fonts"; +import { cn } from "@project-aqua/design-system/lib/utils"; + +const geist = Geist({subsets:['latin'],variable:'--font-sans'}); export default function RootLayout({ children, @@ -11,14 +14,14 @@ export default function RootLayout({ }>) { return ( - - - <>{children} - + + + {children} + ); diff --git a/apps/admin/app/page.tsx b/apps/admin/app/page.tsx deleted file mode 100644 index dc191aa..0000000 --- a/apps/admin/app/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
-
-

- Get started by editing  - app/page.tsx -

- -
- -
- Next.js Logo -
- - -
- ); -} diff --git a/apps/admin/app/team/@modal/(.)[teamId]/swimmers/create/modal.tsx b/apps/admin/app/team/@modal/(.)[teamId]/swimmers/create/modal.tsx deleted file mode 100644 index a6fd5bb..0000000 --- a/apps/admin/app/team/@modal/(.)[teamId]/swimmers/create/modal.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import { type ElementRef, useEffect, useRef } from "react"; -import { Dialog } from "@project-aqua/ui/components/dialog"; -import { useRouter } from "next/navigation"; -import { createPortal } from "react-dom"; - -export function Modal({ children }: { children: React.ReactElement }) { - const router = useRouter(); - const dialogRef = useRef>(null); - - useEffect(() => { - if (!dialogRef.current?.open) { - dialogRef.current?.showModal(); - } - }, []); - - function navigateBack() { - router.back(); - } - - return createPortal( - !open && navigateBack()}> - {children} - , - document.getElementById("modal-root")!, - ); -} diff --git a/apps/admin/app/team/@modal/(.)[teamId]/swimmers/create/page.tsx b/apps/admin/app/team/@modal/(.)[teamId]/swimmers/create/page.tsx deleted file mode 100644 index e1ad938..0000000 --- a/apps/admin/app/team/@modal/(.)[teamId]/swimmers/create/page.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { UploadIcon, CalendarDaysIcon } from "lucide-react"; -import { - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@project-aqua/ui/components/dialog"; -import { Label } from "@project-aqua/ui/components/label"; -import { Input } from "@project-aqua/ui/components/input"; -import { Button } from "@project-aqua/ui/components/button"; -import { - Avatar, - AvatarImage, - AvatarFallback, -} from "@project-aqua/ui/components/avatar"; -import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, -} from "@project-aqua/ui/components/select"; -import { - Popover, - PopoverTrigger, - PopoverContent, -} from "@project-aqua/ui/components/popover"; -import { Calendar } from "@project-aqua/ui/components/calendar"; - -import { Modal } from "./modal"; - -export default function AthleteModal() { - return ( - - - - Add Swimmer - - Fill out the form to add a new swimmer to the team. - - -
-
-
- - - JD - - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - - - - - - - - -
-
-
-
- - - - - - - - - -
-
-
-
-

Primary Contact

-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
-
- - - -
-
- ); -} diff --git a/apps/admin/app/team/@modal/default.tsx b/apps/admin/app/team/@modal/default.tsx deleted file mode 100644 index 4e8611a..0000000 --- a/apps/admin/app/team/@modal/default.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function DefaultModal() { - return null; -} diff --git a/apps/admin/app/team/[teamId]/page.tsx b/apps/admin/app/team/[teamId]/page.tsx deleted file mode 100644 index 395200a..0000000 --- a/apps/admin/app/team/[teamId]/page.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import Link from "next/link"; -import { - Users, - ArrowUpRight, - CreditCard, - DollarSign, - Activity, - CalendarIcon, - CircleX, - CircleCheck, - Calendar, - Award, - TrendingUp, -} from "lucide-react"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@project-aqua/ui/components/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TableHeader, -} from "@project-aqua/ui/components/table"; -import { - Avatar, - AvatarFallback, - AvatarImage, -} from "@project-aqua/ui/components/avatar"; -import { Button, buttonVariants } from "@project-aqua/ui/components/button"; -import { Badge } from "@project-aqua/ui/components/badge"; -import { cn } from "@project-aqua/ui/lib/utils"; - -import type { Metadata, ResolvingMetadata } from "next"; - -type Props = { - params: Promise<{ teamId: string }>; - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -}; - -export async function generateMetadata( - { params, searchParams }: Props, - parent: ResolvingMetadata, -): Promise { - const { teamId } = await params; - const previousMetadata = (await parent).title; - - return { - ...previousMetadata, - alternates: { - canonical: `/team/${teamId}`, - }, - }; -} - -const swimMeets = [ - { - id: 1, - name: "Casteel & Desert Sunrise", - address: "Copper Sky Recreation Complex", - date: "2023-08-31", - paid: true, - }, - { - id: 2, - name: "Corona del Sol", - address: "NOZOMI Park", - date: "2023-09-07", - paid: false, - }, - { - id: 3, - name: "Crosswhite Invitational", - address: "Chandler High School", - date: "2023-09-09", - paid: true, - }, -]; - -export default async function AdminHomePage({ - params, -}: { - params: Promise<{ teamId: string }>; -}) { - const { teamId } = await params; - return ( - <> -

Dashboard

-
-
- - - - Total Swimmers - - - - -
45
-

- +4 from last season -

-
-
- - - - Upcoming Meets - - - - -
3
-

- Next meet in 2 weeks -

-
-
- - - - Team Records - - - - -
12
-

2 new this season

-
-
- - - - Performance Trend - - - - -
+8.2%
-

- Improvement from last meet -

-
-
-
-
- - -
- Upcoming Swim Meets - - A list of all upcoming swim meets for the next 30 days. - -
- - View All - - -
- - - - - Customer - Type - - Status - - Date - Amount - - - - - -
Liam Johnson
-
- liam@example.com -
-
- - Invitational - - - - Approved - - - - 2023-06-23 - - $250.00 -
- - -
Olivia Smith
-
- olivia@example.com -
-
- - Dual Meet - - - - Declined - - - - 2023-06-24 - - $150.00 -
- - -
Noah Williams
-
- noah@example.com -
-
- - Subscription - - - - Approved - - - - 2023-06-25 - - $350.00 -
- - -
Emma Brown
-
- emma@example.com -
-
- Sale - - - Approved - - - - 2023-06-26 - - $450.00 -
- - -
Liam Johnson
-
- liam@example.com -
-
- Sale - - - Approved - - - - 2023-06-27 - - $550.00 -
-
-
-
-
- - - Action Items - - -
- - - OM - -
-

- Olivia Martin -

-

- olivia.martin@email.com -

-
-
+$1,999.00
-
-
- - - JL - -
-

- Jackson Lee -

-

- jackson.lee@email.com -

-
-
+$39.00
-
-
- - - IN - -
-

- Isabella Nguyen -

-

- isabella.nguyen@email.com -

-
-
+$299.00
-
-
- - - WK - -
-

- William Kim -

-

- will@email.com -

-
-
+$99.00
-
-
- - - SD - -
-

- Sofia Davis -

-

- sofia.davis@email.com -

-
-
+$39.00
-
-
-
-
-
- - ); -} diff --git a/apps/admin/app/team/[teamId]/roster/page.tsx b/apps/admin/app/team/[teamId]/roster/page.tsx deleted file mode 100644 index c3c0709..0000000 --- a/apps/admin/app/team/[teamId]/roster/page.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import Link from "next/link"; -import { FileIcon, UserPlusIcon } from "lucide-react"; -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - CardFooter, -} from "@project-aqua/ui/components/card"; -import { Button, buttonVariants } from "@project-aqua/ui/components/button"; -import { - Tabs, - TabsList, - TabsTrigger, - TabsContent, -} from "@project-aqua/ui/components/tabs"; -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell, -} from "@project-aqua/ui/components/table"; -import AthleteInfo from "@/components/roster/athlete-info"; -import { mockAthleteData } from "@/lib/mock-data"; -import { columns } from "@/components/roster/columns"; -import { DataTable } from "@/components/roster/data-table"; - -import { Athlete } from "@/types"; -import type { Metadata, ResolvingMetadata } from "next"; - -async function getData({ teamId }: { teamId: string }): Promise { - return mockAthleteData; -} - -export async function generateMetadata( - { - params, - searchParams, - }: { - params: Promise<{ teamId: string }>; - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; - }, - parent: ResolvingMetadata, -): Promise { - const { teamId } = await params; - - return { - title: "Manager Your Roster", - alternates: { - canonical: `/team/${teamId}/roster`, - }, - description: - "View and manage your swim team's roster. Add, edit, and remove swimmers as needed.", - openGraph: { - title: "Manage Your Roster", - description: - "View and manage your swim team's roster. Add, edit, and remove swimmers as needed.", - type: "website", - url: `/team/${teamId}/roster`, - siteName: "Project Aqua", - }, - }; -} - -export default async function RosterPage({ - params, - searchParams, -}: { - params: Promise<{ teamId: string }>; - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -}) { - const { teamId } = await params; - const rosterData = await getData({ teamId }); - const { athleteId } = await searchParams; - const selectedAthlete = rosterData.find( - (athlete) => athlete.id === athleteId, - ); - const maleSwimmers = rosterData.filter( - (athlete) => athlete.gender === "Male", - ); - - const femaleSwimmers = rosterData.filter( - (athlete) => athlete.gender === "Female", - ); - - return ( - <> -
-

Roster

-
-
-
-
- - - Your Swim Team - - Manage your swim team's performance and progress. - - - - - - Add Swimmer - - - - - - Swimmers - {rosterData.length} - - -
-
- Male Swimmers: {maleSwimmers.length} -
-
- Female Swimmers: {femaleSwimmers.length} -
-
-
-
- - - This Month - 80 Practices - - -
- -
- - Swimmers - Staff - -
- -
-
- - - - Roster - - View and manage your swim team's roster. - - - - - - - - - - - Staff - - View and manage your swim team's staff. - - - - - - - Name - - Role - - - Email - - - Phone - - {/* a table head that a switch would be useful for */} - - Admin - - - - - - Jane Doe - - Coach - - - janedoe@example.com - - - 555-555-5555 - - - -
-
-
-
-
-
-
- {/* {selectedAthlete && } */} -
-
- - ); -} diff --git a/apps/admin/app/team/layout.tsx b/apps/admin/app/team/layout.tsx deleted file mode 100644 index 8e6570e..0000000 --- a/apps/admin/app/team/layout.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { Metadata } from "next"; -import { - SidebarInset, - SidebarProvider, - SidebarTrigger, -} from "@project-aqua/ui/components/sidebar"; -import { AppSidebar } from "@/components/app-sidebar"; -import { Separator } from "@project-aqua/ui/components/separator"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@project-aqua/ui/components/breadcrumb"; - -export const metadata: Metadata = { - metadataBase: new URL("http://localhost:3001"), - title: { - template: "%s | Project Aqua", - default: - "Swim Coach Dashboard: Roster, Upcoming Meets, and More | Project Aqua", - }, - description: - "Stay on top of your swim team's activities with Project Aqua's Swim Coach Dashboard. View your roster, track upcoming meets, and access other essential tools for effective team management.", -}; - -export default function AdminLayout({ - children, - modal, -}: Readonly<{ - children: React.ReactNode; - modal: React.ReactNode; -}>) { - return ( - - - -
-
- - - - - - - Building Your Application - - - - - Data Fetching - - - -
-
-
{children}
-
-