This commit is contained in:
parent
8059b22274
commit
a7a56fd5e0
13
.dockerignore
Normal file
13
.dockerignore
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
*.db
|
||||
docker
|
||||
.github
|
||||
.w4c
|
||||
tests
|
||||
README.md
|
||||
coverage
|
||||
13
.env.example
Normal file
13
.env.example
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# --- Core -------------------------------------------------------------------
|
||||
DATABASE_URL="file:./dev.db"
|
||||
NUXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||
|
||||
# --- Payments (optional) ----------------------------------------------------
|
||||
# Leave empty to use the built-in mock provider (no keys needed).
|
||||
STRIPE_SECRET_KEY=""
|
||||
STRIPE_WEBHOOK_SECRET=""
|
||||
|
||||
# --- Email (optional) -------------------------------------------------------
|
||||
# Leave empty to use the built-in log provider (no keys needed).
|
||||
RESEND_API_KEY=""
|
||||
EMAIL_FROM="Acme <noreply@example.com>"
|
||||
33
.github/workflows/ci.yml
vendored
Normal file
33
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: "file:./ci.db"
|
||||
NUXT_PUBLIC_SITE_URL: "http://localhost:3000"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run typecheck
|
||||
- run: npm run db:setup
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
- name: e2e smoke
|
||||
run: |
|
||||
npm start &
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://127.0.0.1:3000/api/health >/dev/null; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
npm run test:e2e
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.data
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
.env.local
|
||||
*.db
|
||||
*.db-journal
|
||||
BIN
.w4c/preview.png
Normal file
BIN
.w4c/preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
31
.w4c/qa.json
Normal file
31
.w4c/qa.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"install": "npm ci",
|
||||
"setup": "npm run db:setup",
|
||||
"lint": "npm run lint",
|
||||
"typecheck": "npm run typecheck",
|
||||
"unit": "npm run test",
|
||||
"build": "npm run build",
|
||||
"start": "npm run start",
|
||||
"e2e": "node tests/e2e/smoke.mjs",
|
||||
"port": 3000,
|
||||
"health": "/api/health",
|
||||
"env": {
|
||||
"DATABASE_URL": "file:./dev.db",
|
||||
"NUXT_PUBLIC_SITE_URL": "http://localhost:3000"
|
||||
},
|
||||
"routes": [
|
||||
{ "path": "/", "expect": [200] },
|
||||
{ "path": "/pricing", "expect": [200] },
|
||||
{ "path": "/login", "expect": [200] },
|
||||
{ "path": "/register", "expect": [200] },
|
||||
{ "path": "/legal/privacy", "expect": [200] },
|
||||
{ "path": "/dashboard", "expect": [302, 307] },
|
||||
{ "path": "/admin", "expect": [302, 307] },
|
||||
{ "path": "/api/health", "expect": [200] },
|
||||
{ "path": "/api/auth/me", "expect": [401] },
|
||||
{ "path": "/api/admin/users", "expect": [401] },
|
||||
{ "path": "/sitemap.xml", "expect": [200] },
|
||||
{ "path": "/robots.txt", "expect": [200] }
|
||||
],
|
||||
"timeout": { "install": 900, "build": 900, "boot": 120 }
|
||||
}
|
||||
30
.w4c/template.json
Normal file
30
.w4c/template.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"schema": 2,
|
||||
"system": "v2",
|
||||
"catalog": false,
|
||||
"id": "base-nuxt",
|
||||
"case": "base",
|
||||
"stack": "nuxt",
|
||||
"title": "Base — Nuxt 3",
|
||||
"description": "The shared core on Nuxt: auth, roles, Prisma, mock payments/email, admin shell, SEO, CI and Docker.",
|
||||
"tags": ["base", "nuxt", "starter"],
|
||||
"preview": ".w4c/preview.png",
|
||||
"agentId": "w4c-start2",
|
||||
"defaultName": "my-app",
|
||||
"featured": false,
|
||||
"env": [
|
||||
{ "key": "DATABASE_URL", "required": false, "note": "SQLite by default; Postgres in production" },
|
||||
{ "key": "NUXT_PUBLIC_SITE_URL", "required": false, "note": "Public origin for sitemap/OG/checkout" },
|
||||
{ "key": "STRIPE_SECRET_KEY", "required": false, "note": "mock provider is used when absent" },
|
||||
{ "key": "RESEND_API_KEY", "required": false, "note": "log provider is used when absent" }
|
||||
],
|
||||
"modules": [
|
||||
{ "id": "resend-email", "skill": "integration-resend-email" },
|
||||
{ "id": "s3-storage", "skill": "integration-s3-storage" },
|
||||
{ "id": "analytics", "skill": "integration-analytics" }
|
||||
],
|
||||
"seededAccounts": [
|
||||
{ "role": "admin", "email": "admin@example.com", "password": "password" },
|
||||
{ "role": "user", "email": "user@example.com", "password": "password" }
|
||||
]
|
||||
}
|
||||
66
README.md
66
README.md
|
|
@ -1,3 +1,65 @@
|
|||
# templates2.Base.Nuxt
|
||||
# Base — Nuxt 3 (templates2)
|
||||
|
||||
The shared core on Nuxt: auth, roles, Prisma, mock payments/email, admin shell, SEO, CI and Docker.
|
||||
A **ready-to-run** Nuxt application — the shared core every Nuxt v2 case forks: authentication,
|
||||
roles, Prisma, mock payments and email, an admin shell, SEO, i18n, observability, CI and a Docker
|
||||
image. Feature-parity with `templates2.Base.Next`.
|
||||
|
||||
> Not listed in the user catalog (`"catalog": false` in `.w4c/template.json`).
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
cp .env.example .env # SQLite + mock providers by default — no keys needed
|
||||
npm run db:setup # prisma db push + seed
|
||||
npm run dev # http://localhost:3000
|
||||
```
|
||||
|
||||
Demo accounts (seeded): `admin@example.com` (admin) and `user@example.com` (user) — password
|
||||
`password`.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `npm run dev` | Dev server with HMR |
|
||||
| `npm run build` / `npm start` | Production build / serve `.output` |
|
||||
| `npm run lint` / `npm run typecheck` | ESLint / `nuxt typecheck` |
|
||||
| `npm test` | Unit tests (Vitest) |
|
||||
| `npm run test:e2e` | HTTP end-to-end smoke (server must be running) |
|
||||
| `npm run db:setup` | `prisma db push` + seed |
|
||||
|
||||
## What works out of the box
|
||||
|
||||
- **Auth & roles** — email + password, bcrypt, httpOnly cookie sessions (`Session` table), `user`
|
||||
and `admin` roles. `server/middleware/guard.ts` gates protected pages before render; server
|
||||
routes enforce authorization (`requireUser`, `requireAdmin`).
|
||||
- **Payments** — provider interface: a **mock provider with no keys** (activates the subscription
|
||||
directly) plus Stripe from env. Webhooks are signature-checked and idempotent.
|
||||
- **Email** — log provider by default, Resend from env.
|
||||
- **Admin** — role-gated shell, stats dashboard, users table (role change / delete with
|
||||
confirmation) and an audit log.
|
||||
- **SEO** — `useSeoMeta` per page, JSON-LD, `sitemap.xml` and `robots.txt` served from
|
||||
`server/routes`.
|
||||
- **i18n** — English dictionary in `shared/en.json`, `t()` in `app/utils/i18n.ts`.
|
||||
- **Observability** — `/api/health`, empty/loading states, error handling.
|
||||
- **Delivery** — multi-stage Dockerfile with a healthcheck; CI runs lint + typecheck + unit + build
|
||||
+ e2e.
|
||||
|
||||
## Data model
|
||||
|
||||
`prisma/schema.prisma`: `User`, `Session`, `Subscription`, `AuditLog` — identical to the Next base.
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
## Quality
|
||||
|
||||
```bash
|
||||
node templates2/qa.mjs Base.Nuxt
|
||||
```
|
||||
|
||||
See [`../CONTRACT.md`](../CONTRACT.md) for the Definition of Done.
|
||||
|
|
|
|||
12
app/app.vue
Normal file
12
app/app.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
const themeScript = `try{var t=localStorage.getItem('theme');var d=t? t==='dark' : matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');}catch(e){}`;
|
||||
useHead({
|
||||
script: [{ innerHTML: themeScript, tagPriority: 'critical' }],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
132
app/assets/css/main.css
Normal file
132
app/assets/css/main.css
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-brand-50: #eef2ff;
|
||||
--color-brand-100: #e0e7ff;
|
||||
--color-brand-200: #c7d2fe;
|
||||
--color-brand-400: #818cf8;
|
||||
--color-brand-500: #6366f1;
|
||||
--color-brand-600: #4f46e5;
|
||||
--color-brand-700: #4338ca;
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f8fafc;
|
||||
--border: #e2e8f0;
|
||||
--text: #0f172a;
|
||||
--text-muted: #52607a;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--surface: #0b1120;
|
||||
--surface-muted: #111827;
|
||||
--border: #1f2937;
|
||||
--text: #e5e7eb;
|
||||
--text-muted: #94a3b8;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.page {
|
||||
@apply mx-auto w-full max-w-6xl px-5;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.card {
|
||||
@apply rounded-2xl border p-6;
|
||||
background: var(--surface-muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn,
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-ghost,
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition;
|
||||
}
|
||||
.btn:disabled,
|
||||
.btn-primary:disabled,
|
||||
.btn-secondary:disabled,
|
||||
.btn-ghost:disabled,
|
||||
.btn-danger:disabled {
|
||||
@apply cursor-not-allowed opacity-60;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply text-white;
|
||||
background: var(--color-brand-600);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-brand-700);
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply border;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.btn-ghost {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
@apply bg-red-700;
|
||||
}
|
||||
.label {
|
||||
@apply mb-1 block text-sm font-medium;
|
||||
}
|
||||
.input {
|
||||
@apply w-full rounded-lg border px-3 py-2 text-sm outline-none;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--color-brand-500);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand-500) 25%, transparent);
|
||||
}
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-brand-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
64
app/components/AuthForm.vue
Normal file
64
app/components/AuthForm.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<script setup lang="ts">
|
||||
const props = defineProps<{ mode: 'login' | 'register' }>();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const next = computed(() => (route.query.next as string) || '/dashboard');
|
||||
const busy = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const isRegister = computed(() => props.mode === 'register');
|
||||
|
||||
async function onSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
const form = new FormData(e.target as HTMLFormElement);
|
||||
const body = Object.fromEntries(form.entries());
|
||||
try {
|
||||
await $fetch(`/api/auth/${props.mode}`, { method: 'POST', body });
|
||||
await router.push(next.value);
|
||||
await refreshNuxtData('current-user');
|
||||
} catch (err: unknown) {
|
||||
error.value = (err as { statusMessage?: string; message?: string })?.statusMessage || 'Request failed';
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h1 class="text-xl font-semibold">{{ isRegister ? t('auth.registerTitle') : t('auth.loginTitle') }}</h1>
|
||||
<form class="mt-6 space-y-4" novalidate @submit="onSubmit">
|
||||
<div v-if="isRegister">
|
||||
<label class="label" for="name">{{ t('auth.name') }}</label>
|
||||
<input id="name" name="name" class="input" autocomplete="name">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="email">{{ t('auth.email') }}</label>
|
||||
<input id="email" name="email" type="email" required class="input" autocomplete="email">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="password">{{ t('auth.password') }}</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
class="input"
|
||||
:autocomplete="isRegister ? 'new-password' : 'current-password'"
|
||||
>
|
||||
</div>
|
||||
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
<button type="submit" class="btn-primary w-full" :disabled="busy">
|
||||
{{ busy ? '…' : isRegister ? t('auth.submitRegister') : t('auth.submitLogin') }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="muted mt-5 text-center text-sm">
|
||||
{{ isRegister ? t('auth.hasAccount') : t('auth.noAccount') }}
|
||||
<NuxtLink :to="isRegister ? '/login' : '/register'" class="font-medium text-brand-600 hover:underline">
|
||||
{{ isRegister ? t('auth.submitLogin') : t('auth.submitRegister') }}
|
||||
</NuxtLink>
|
||||
</p>
|
||||
<p v-if="!isRegister" class="muted mt-3 text-center text-xs">{{ t('auth.demoHint') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
12
app/components/SiteFooter.vue
Normal file
12
app/components/SiteFooter.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<footer class="border-t">
|
||||
<div class="page flex flex-col items-center justify-between gap-4 py-8 text-sm sm:flex-row">
|
||||
<p class="muted">© {{ new Date().getFullYear() }} {{ t('brand.name') }}</p>
|
||||
<nav class="flex gap-5" aria-label="Footer">
|
||||
<NuxtLink to="/pricing" class="muted hover:opacity-80">{{ t('nav.pricing') }}</NuxtLink>
|
||||
<NuxtLink to="/legal/privacy" class="muted hover:opacity-80">Privacy</NuxtLink>
|
||||
<NuxtLink to="/legal/terms" class="muted hover:opacity-80">Terms</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
26
app/components/SiteHeader.vue
Normal file
26
app/components/SiteHeader.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import type { SessionUser } from '#shared/types';
|
||||
|
||||
defineProps<{ user: SessionUser | null }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="border-b">
|
||||
<div class="page flex h-16 items-center justify-between gap-4">
|
||||
<NuxtLink to="/" class="text-lg font-bold tracking-tight">{{ t('brand.name') }}</NuxtLink>
|
||||
<nav class="hidden items-center gap-6 text-sm md:flex" aria-label="Main">
|
||||
<NuxtLink to="/" class="muted hover:opacity-80">{{ t('nav.home') }}</NuxtLink>
|
||||
<NuxtLink to="/pricing" class="muted hover:opacity-80">{{ t('nav.pricing') }}</NuxtLink>
|
||||
<NuxtLink v-if="user" to="/dashboard" class="muted hover:opacity-80">{{ t('nav.dashboard') }}</NuxtLink>
|
||||
<NuxtLink v-if="user?.role === 'admin'" to="/admin" class="muted hover:opacity-80">{{ t('nav.admin') }}</NuxtLink>
|
||||
</nav>
|
||||
<div class="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<form v-if="user" action="/api/auth/logout" method="post">
|
||||
<button type="submit" class="btn-secondary">{{ t('nav.logout') }}</button>
|
||||
</form>
|
||||
<NuxtLink v-else to="/login" class="btn-primary">{{ t('nav.login') }}</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
27
app/components/ThemeToggle.vue
Normal file
27
app/components/ThemeToggle.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
const isDark = ref(false);
|
||||
onMounted(() => {
|
||||
isDark.value = document.documentElement.classList.contains('dark');
|
||||
});
|
||||
function toggle() {
|
||||
isDark.value = !isDark.value;
|
||||
document.documentElement.classList.toggle('dark', isDark.value);
|
||||
try {
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
:aria-label="isDark ? 'Switch to light theme' : 'Switch to dark theme'"
|
||||
@click="toggle"
|
||||
>
|
||||
<span aria-hidden>{{ isDark ? '☾' : '☀' }}</span>
|
||||
<span class="hidden sm:inline">{{ isDark ? 'Dark' : 'Light' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
34
app/components/UpgradeButton.vue
Normal file
34
app/components/UpgradeButton.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
const props = defineProps<{ plan: string; label: string }>();
|
||||
const router = useRouter();
|
||||
const busy = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function upgrade() {
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { url } = await $fetch<{ url: string }>('/api/billing/checkout', { method: 'POST', body: { plan: props.plan } });
|
||||
await router.push(url.replace(/^https?:\/\/[^/]+/, ''));
|
||||
await refreshNuxtData('current-user');
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { statusCode?: number })?.statusCode;
|
||||
if (status === 401) {
|
||||
await router.push('/login?next=/pricing');
|
||||
return;
|
||||
}
|
||||
error.value = 'Checkout failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6">
|
||||
<button type="button" class="btn-primary w-full" :disabled="busy" @click="upgrade">
|
||||
{{ busy ? 'Working…' : label }}
|
||||
</button>
|
||||
<p v-if="error" class="mt-2 text-sm text-red-600">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
28
app/components/UserActions.vue
Normal file
28
app/components/UserActions.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<script setup lang="ts">
|
||||
const props = defineProps<{ id: string; role: string }>();
|
||||
const busy = ref(false);
|
||||
|
||||
async function update(role: string) {
|
||||
busy.value = true;
|
||||
await $fetch(`/api/admin/users/${props.id}`, { method: 'PATCH', body: { role } }).catch(() => undefined);
|
||||
busy.value = false;
|
||||
await refreshNuxtData('admin-users');
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!window.confirm('Delete this user? This cannot be undone.')) return;
|
||||
busy.value = true;
|
||||
await $fetch(`/api/admin/users/${props.id}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
busy.value = false;
|
||||
await refreshNuxtData('admin-users');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn-secondary" :disabled="busy" @click="update(props.role === 'admin' ? 'user' : 'admin')">
|
||||
{{ props.role === 'admin' ? 'Make user' : 'Make admin' }}
|
||||
</button>
|
||||
<button type="button" class="btn-danger" :disabled="busy" @click="remove">Delete</button>
|
||||
</div>
|
||||
</template>
|
||||
15
app/components/ui/UiAlert.vue
Normal file
15
app/components/ui/UiAlert.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
const tones: Record<string, string> = {
|
||||
info: 'border-brand-400 bg-brand-50 text-brand-700 dark:bg-brand-700/20 dark:text-brand-100',
|
||||
success: 'border-emerald-500 bg-emerald-50 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-100',
|
||||
warning: 'border-amber-400 bg-amber-50 text-black',
|
||||
error: 'border-red-500 bg-red-50 text-red-800 dark:bg-red-900/30 dark:text-red-100',
|
||||
};
|
||||
const props = withDefaults(defineProps<{ tone?: string }>(), { tone: 'info' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div role="status" class="rounded-lg border px-4 py-3 text-sm" :class="tones[props.tone] ?? tones.info">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
app/components/ui/UiBadge.vue
Normal file
14
app/components/ui/UiBadge.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
const tones: Record<string, string> = {
|
||||
neutral: 'bg-slate-200 text-slate-800 dark:bg-slate-700 dark:text-slate-100',
|
||||
success: 'bg-emerald-600 text-white',
|
||||
warning: 'bg-amber-300 text-black',
|
||||
danger: 'bg-red-600 text-white',
|
||||
brand: 'bg-brand-600 text-white',
|
||||
};
|
||||
const props = withDefaults(defineProps<{ tone?: string }>(), { tone: 'neutral' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="badge" :class="tones[props.tone] ?? tones.neutral"><slot /></span>
|
||||
</template>
|
||||
14
app/components/ui/UiCard.vue
Normal file
14
app/components/ui/UiCard.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ title?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card">
|
||||
<header v-if="title || $slots.actions" class="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 v-if="title" class="text-lg font-semibold">{{ title }}</h2>
|
||||
<span v-else />
|
||||
<slot name="actions" />
|
||||
</header>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
18
app/composables/useSession.ts
Normal file
18
app/composables/useSession.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { SessionUser } from '#shared/types';
|
||||
|
||||
export type Session = { user: SessionUser | null; plan: string };
|
||||
|
||||
/** Fetch the current session (user + plan) once per request and share it across components. */
|
||||
export function useSession() {
|
||||
return useAsyncData<Session>(
|
||||
'session',
|
||||
async () => {
|
||||
try {
|
||||
return await $fetch<Session>('/api/auth/me');
|
||||
} catch {
|
||||
return { user: null, plan: 'free' };
|
||||
}
|
||||
},
|
||||
{ default: () => ({ user: null, plan: 'free' }) },
|
||||
);
|
||||
}
|
||||
39
app/layouts/admin.vue
Normal file
39
app/layouts/admin.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
const { data: session } = await useSession();
|
||||
if (!session.value.user) {
|
||||
await navigateTo('/login?next=/admin');
|
||||
} else if (session.value.user.role !== 'admin') {
|
||||
await navigateTo('/dashboard');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<header class="border-b">
|
||||
<div class="page flex h-16 items-center justify-between">
|
||||
<NuxtLink to="/" class="text-lg font-bold">
|
||||
{{ t('brand.name') }} <span class="muted font-normal">/ {{ t('admin.title') }}</span>
|
||||
</NuxtLink>
|
||||
<span class="muted text-sm">{{ session.user?.email }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="page flex flex-1 flex-col gap-6 py-8 md:flex-row">
|
||||
<aside class="md:w-52 md:shrink-0">
|
||||
<nav class="flex gap-1 md:flex-col" aria-label="Admin">
|
||||
<NuxtLink to="/admin" class="rounded-lg px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10">
|
||||
{{ t('admin.overview') }}
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/users" class="rounded-lg px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10">
|
||||
{{ t('admin.users') }}
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/audit" class="rounded-lg px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10">
|
||||
{{ t('admin.audit') }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="min-w-0 flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
8
app/layouts/auth.vue
Normal file
8
app/layouts/auth.vue
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<template>
|
||||
<div class="flex min-h-screen flex-col items-center justify-center px-5 py-12">
|
||||
<NuxtLink to="/" class="mb-8 text-xl font-bold">{{ t('brand.name') }}</NuxtLink>
|
||||
<div class="w-full max-w-sm">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
13
app/layouts/default.vue
Normal file
13
app/layouts/default.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
const { data: session } = await useSession();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<SiteHeader :user="session.user" />
|
||||
<main class="flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</template>
|
||||
22
app/pages/admin/audit.vue
Normal file
22
app/pages/admin/audit.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'admin' });
|
||||
useSeoMeta({ title: 'Audit · Admin', robots: 'noindex' });
|
||||
const { data } = await useFetch('/api/admin/audit', { key: 'admin-audit' });
|
||||
const entries = computed(() => data.value?.entries ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold">{{ t('admin.audit') }}</h1>
|
||||
<p v-if="entries.length === 0" class="muted py-6 text-center text-sm">{{ t('common.empty') }}</p>
|
||||
<ul v-else class="divide-y rounded-xl border">
|
||||
<li v-for="e in entries" :key="e.id" class="flex items-center justify-between gap-4 px-4 py-3 text-sm">
|
||||
<span>
|
||||
<span class="font-medium">{{ e.action }}</span>
|
||||
<span v-if="e.target" class="muted"> → {{ e.target }}</span>
|
||||
</span>
|
||||
<span class="muted shrink-0 text-xs">{{ e.actor }} · {{ formatDate(e.createdAt) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
32
app/pages/admin/index.vue
Normal file
32
app/pages/admin/index.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'admin' });
|
||||
useSeoMeta({ title: 'Admin · Acme', robots: 'noindex' });
|
||||
const { data: stats } = await useFetch('/api/admin/stats', { key: 'admin-stats' });
|
||||
const cards = computed(() => [
|
||||
{ label: t('admin.totalUsers'), value: stats.value?.totalUsers ?? 0 },
|
||||
{ label: t('admin.activeSubs'), value: stats.value?.activeSubs ?? 0 },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold">{{ t('admin.overview') }}</h1>
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div v-for="s in cards" :key="s.label" class="card">
|
||||
<p class="muted text-sm">{{ s.label }}</p>
|
||||
<p class="mt-2 text-3xl font-bold">{{ s.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<UiCard :title="t('admin.audit')">
|
||||
<ul class="divide-y">
|
||||
<li v-for="entry in stats?.recent ?? []" :key="entry.id" class="flex items-center justify-between py-2 text-sm">
|
||||
<span>
|
||||
<span class="font-medium">{{ entry.action }}</span>
|
||||
<span v-if="entry.target" class="muted"> → {{ entry.target }}</span>
|
||||
</span>
|
||||
<span class="muted text-xs">{{ entry.actor }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
35
app/pages/admin/users.vue
Normal file
35
app/pages/admin/users.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'admin' });
|
||||
useSeoMeta({ title: 'Users · Admin', robots: 'noindex' });
|
||||
const { data } = await useFetch('/api/admin/users', { key: 'admin-users' });
|
||||
const users = computed(() => data.value?.users ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold">{{ t('admin.users') }}</h1>
|
||||
<p v-if="users.length === 0" class="muted py-6 text-center text-sm">{{ t('common.empty') }}</p>
|
||||
<div v-else class="overflow-x-auto rounded-xl border">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="muted text-left text-xs uppercase">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Email</th>
|
||||
<th class="px-4 py-3">Role</th>
|
||||
<th class="px-4 py-3">Plan</th>
|
||||
<th class="px-4 py-3">Created</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id" class="border-t">
|
||||
<td class="px-4 py-3 font-medium">{{ u.email }}</td>
|
||||
<td class="px-4 py-3"><UiBadge :tone="u.role === 'admin' ? 'brand' : 'neutral'">{{ u.role }}</UiBadge></td>
|
||||
<td class="px-4 py-3">{{ u.subscription?.plan ?? 'free' }}</td>
|
||||
<td class="px-4 py-3">{{ formatDate(u.createdAt) }}</td>
|
||||
<td class="px-4 py-3"><UserActions :id="u.id" :role="u.role" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
42
app/pages/dashboard.vue
Normal file
42
app/pages/dashboard.vue
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<script setup lang="ts">
|
||||
useSeoMeta({ title: 'Dashboard · Acme', robots: 'noindex' });
|
||||
const { data: session } = await useSession();
|
||||
if (!session.value.user) await navigateTo('/login?next=/dashboard');
|
||||
const { data: usage } = await useFetch('/api/me/usage', { key: 'me-usage' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page space-y-6 py-10">
|
||||
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ t('dashboard.title') }}</h1>
|
||||
<p class="muted mt-1">{{ t('dashboard.welcome', { name: session.user?.name || session.user?.email || '' }) }}</p>
|
||||
</div>
|
||||
<UiBadge :tone="session.plan === 'pro' ? 'brand' : 'neutral'">{{ PLANS[session.plan as 'free' | 'pro']?.name ?? 'Free' }}</UiBadge>
|
||||
</header>
|
||||
|
||||
<UiCard :title="t('dashboard.plan')">
|
||||
<template #actions>
|
||||
<NuxtLink to="/pricing" class="btn-secondary">{{ t('pricing.choose') }}</NuxtLink>
|
||||
</template>
|
||||
<p class="muted text-sm">{{ PLANS[session.plan as 'free' | 'pro']?.name }} — {{ formatCurrency(PLANS[session.plan as 'free' | 'pro']?.price ?? 0) }} / month</p>
|
||||
</UiCard>
|
||||
|
||||
<UiCard :title="t('dashboard.usage')">
|
||||
<ul class="space-y-4">
|
||||
<li v-for="m in usage?.metrics ?? []" :key="m.label">
|
||||
<div class="mb-1 flex justify-between text-sm">
|
||||
<span>{{ m.label }}</span>
|
||||
<span class="muted">{{ m.used }} / {{ m.limit }}</span>
|
||||
</div>
|
||||
<div class="h-2 w-full overflow-hidden rounded-full" style="background: var(--border)">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
:style="{ width: Math.min(100, Math.round((m.used / m.limit) * 100)) + '%', background: 'var(--color-brand-600)' }"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
39
app/pages/index.vue
Normal file
39
app/pages/index.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
useSeoMeta({
|
||||
title: 'Acme — ready-to-run app',
|
||||
description: 'A production-ready Nuxt app: auth, roles, payments, admin, SEO — working on day one.',
|
||||
});
|
||||
const config = useRuntimeConfig();
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({ '@context': 'https://schema.org', '@type': 'WebSite', name: t('brand.name'), url: config.public.siteUrl }),
|
||||
},
|
||||
],
|
||||
});
|
||||
const features = ['auth', 'billing', 'admin', 'seo'] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="page py-20 text-center">
|
||||
<h1 class="mx-auto max-w-3xl text-4xl font-extrabold tracking-tight sm:text-5xl">{{ t('home.title') }}</h1>
|
||||
<p class="muted mx-auto mt-5 max-w-2xl text-lg">{{ t('home.subtitle') }}</p>
|
||||
<div class="mt-8 flex justify-center gap-3">
|
||||
<NuxtLink to="/register" class="btn-primary">{{ t('home.cta') }}</NuxtLink>
|
||||
<NuxtLink to="/pricing" class="btn-secondary">{{ t('home.secondaryCta') }}</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page pb-24">
|
||||
<h2 class="mb-8 text-center text-2xl font-bold">{{ t('home.featuresTitle') }}</h2>
|
||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div v-for="key in features" :key="key" class="card">
|
||||
<h3 class="text-base font-semibold">{{ t(`home.features.${key}.title`) }}</h3>
|
||||
<p class="muted mt-2 text-sm">{{ t(`home.features.${key}.body`) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
10
app/pages/legal/privacy.vue
Normal file
10
app/pages/legal/privacy.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
useSeoMeta({ title: 'Privacy Policy · Acme' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="page max-w-3xl py-16">
|
||||
<h1 class="text-3xl font-bold">Privacy Policy</h1>
|
||||
<p class="muted mt-4">This is a placeholder legal page. Replace it with your own policy before going live.</p>
|
||||
</article>
|
||||
</template>
|
||||
10
app/pages/legal/terms.vue
Normal file
10
app/pages/legal/terms.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
useSeoMeta({ title: 'Terms of Service · Acme' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="page max-w-3xl py-16">
|
||||
<h1 class="text-3xl font-bold">Terms of Service</h1>
|
||||
<p class="muted mt-4">This is a placeholder legal page. Replace it with your own terms before going live.</p>
|
||||
</article>
|
||||
</template>
|
||||
10
app/pages/login.vue
Normal file
10
app/pages/login.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'auth' });
|
||||
useSeoMeta({ title: 'Sign in · Acme' });
|
||||
const { data: session } = await useSession();
|
||||
if (session.value.user) await navigateTo('/dashboard');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthForm mode="login" />
|
||||
</template>
|
||||
52
app/pages/pricing.vue
Normal file
52
app/pages/pricing.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
useSeoMeta({ title: 'Pricing · Acme', description: 'Simple pricing — start free, upgrade when you grow.' });
|
||||
const route = useRoute();
|
||||
const { data: session } = await useSession();
|
||||
const config = useRuntimeConfig();
|
||||
const plans = PLAN_IDS;
|
||||
const upgraded = computed(() => route.query.billing === 'success');
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: t('brand.name'),
|
||||
applicationCategory: 'BusinessApplication',
|
||||
offers: plans.map((id) => ({ '@type': 'Offer', name: PLANS[id].name, price: PLANS[id].price, priceCurrency: 'USD' })),
|
||||
url: config.public.siteUrl,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page py-16">
|
||||
<h1 class="text-center text-3xl font-bold">{{ t('pricing.title') }}</h1>
|
||||
<p class="muted mt-3 text-center">{{ t('pricing.subtitle') }}</p>
|
||||
<UiAlert v-if="upgraded" tone="success" class="mx-auto mt-6 max-w-md text-center">{{ t('pricing.upgraded') }}</UiAlert>
|
||||
<div class="mx-auto mt-10 grid max-w-4xl gap-6 sm:grid-cols-2">
|
||||
<div v-for="id in plans" :key="id" class="card flex flex-col">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold">{{ PLANS[id].name }}</h2>
|
||||
<UiBadge v-if="session.user && session.plan === id" tone="brand">Current</UiBadge>
|
||||
</div>
|
||||
<p class="mt-4 text-3xl font-bold">
|
||||
{{ formatCurrency(PLANS[id].price) }}
|
||||
<span class="muted text-base font-normal"> {{ t('pricing.perMonth') }}</span>
|
||||
</p>
|
||||
<ul class="muted mt-5 flex-1 space-y-2 text-sm">
|
||||
<li v-for="f in PLANS[id].features" :key="f">• {{ f }}</li>
|
||||
</ul>
|
||||
<p v-if="session.user && session.plan === id" class="mt-6 text-center text-sm font-medium text-emerald-600">
|
||||
{{ t('pricing.current') }}
|
||||
</p>
|
||||
<UpgradeButton v-else-if="session.user" :plan="id" :label="t('pricing.choose')" />
|
||||
<NuxtLink v-else :to="'/login?next=/pricing'" class="btn-primary mt-6 w-full">{{ t('pricing.choose') }}</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
10
app/pages/register.vue
Normal file
10
app/pages/register.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'auth' });
|
||||
useSeoMeta({ title: 'Create account · Acme' });
|
||||
const { data: session } = await useSession();
|
||||
if (session.value.user) await navigateTo('/dashboard');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthForm mode="register" />
|
||||
</template>
|
||||
1
app/utils/i18n.ts
Normal file
1
app/utils/i18n.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { t } from '#shared/i18n';
|
||||
1
app/utils/plans.ts
Normal file
1
app/utils/plans.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { PLANS, PLAN_IDS, getPlan, isPlanId, withinLimit, formatCurrency, formatDate, slugify } from '#shared/plans';
|
||||
21
docker/Dockerfile
Normal file
21
docker/Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Build stage
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat openssl
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci || npm install
|
||||
COPY . .
|
||||
RUN npx prisma generate && npm run build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN apk add --no-cache openssl && addgroup -S app && adduser -S app -G app
|
||||
COPY --from=build /app/.output ./.output
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
USER app
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
15
docker/docker-compose.yml
Normal file
15
docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
services:
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: "file:/tmp/prod.db"
|
||||
NUXT_PUBLIC_SITE_URL: "http://localhost:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
8
eslint.config.mjs
Normal file
8
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import withNuxt from './.nuxt/eslint.config.mjs';
|
||||
|
||||
export default withNuxt({
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
},
|
||||
});
|
||||
37
nuxt.config.ts
Normal file
37
nuxt.config.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-01-01',
|
||||
devtools: { enabled: false },
|
||||
modules: ['@nuxt/eslint'],
|
||||
css: ['~/assets/css/main.css'],
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: 'en' },
|
||||
title: 'Acme — ready-to-run app',
|
||||
meta: [
|
||||
{ name: 'description', content: 'A production-ready Nuxt app: auth, roles, payments, admin, SEO — working on day one.' },
|
||||
{ property: 'og:title', content: 'Acme — ready-to-run app' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
},
|
||||
},
|
||||
runtimeConfig: {
|
||||
databaseUrl: process.env.DATABASE_URL || 'file:./dev.db',
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
resendApiKey: process.env.RESEND_API_KEY || '',
|
||||
emailFrom: process.env.EMAIL_FROM || 'Acme <noreply@example.com>',
|
||||
public: {
|
||||
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
nitro: {
|
||||
preset: 'node-server',
|
||||
// Keep Prisma as a runtime dependency of the built server (its query engine lives in node_modules).
|
||||
externals: { external: ['@prisma/client'] },
|
||||
},
|
||||
});
|
||||
14863
package-lock.json
generated
Normal file
14863
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
46
package.json
Normal file
46
package.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "templates2-base-nuxt",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"start": "node .output/server/index.mjs",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "nuxt typecheck",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "node tests/e2e/smoke.mjs",
|
||||
"db:push": "prisma db push --skip-generate",
|
||||
"db:seed": "prisma db seed",
|
||||
"db:setup": "prisma db push --skip-generate && prisma db seed",
|
||||
"postinstall": "nuxt prepare && prisma generate"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"overrides": {
|
||||
"h3": "1.15.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.3.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"nuxt": "^4.0.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.5.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint": "^1.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"eslint": "^9.0.0",
|
||||
"prisma": "^6.3.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.8",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
58
prisma/schema.prisma
Normal file
58
prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Templates2 Base — core data model shared by every case.
|
||||
// SQLite in development (file:./dev.db); switch the datasource url to Postgres in production.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String?
|
||||
role String @default("user") // user | admin
|
||||
createdAt DateTime @default(now())
|
||||
sessions Session[]
|
||||
subscription Subscription?
|
||||
auditLogs AuditLog[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
token String @unique
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
plan String @default("free") // free | pro
|
||||
status String @default("active") // active | canceled | past_due
|
||||
provider String @default("mock") // mock | stripe
|
||||
currentPeriodEnd DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
actorId String?
|
||||
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
|
||||
action String
|
||||
target String?
|
||||
meta String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([actorId])
|
||||
}
|
||||
51
prisma/seed.ts
Normal file
51
prisma/seed.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const db = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const password = await bcrypt.hash('password', 10);
|
||||
|
||||
const admin = await db.user.upsert({
|
||||
where: { email: 'admin@example.com' },
|
||||
update: { role: 'admin' },
|
||||
create: { email: 'admin@example.com', name: 'Admin User', role: 'admin', passwordHash: password },
|
||||
});
|
||||
|
||||
const user = await db.user.upsert({
|
||||
where: { email: 'user@example.com' },
|
||||
update: {},
|
||||
create: { email: 'user@example.com', name: 'Demo User', role: 'user', passwordHash: password },
|
||||
});
|
||||
|
||||
await db.subscription.upsert({
|
||||
where: { userId: admin.id },
|
||||
update: {},
|
||||
create: { userId: admin.id, plan: 'pro', status: 'active', provider: 'mock' },
|
||||
});
|
||||
await db.subscription.upsert({
|
||||
where: { userId: user.id },
|
||||
update: {},
|
||||
create: { userId: user.id, plan: 'free', status: 'active', provider: 'mock' },
|
||||
});
|
||||
|
||||
if ((await db.auditLog.count()) === 0) {
|
||||
await db.auditLog.createMany({
|
||||
data: [
|
||||
{ actorId: admin.id, action: 'seed', target: 'system', meta: 'Initial demo data' },
|
||||
{ actorId: admin.id, action: 'user.create', target: user.email, meta: 'Seeded demo user' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Seeded: ${admin.email} (admin), ${user.email} (user) — password: "password"`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await db.$disconnect();
|
||||
});
|
||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="7" fill="#4f46e5"/>
|
||||
<path d="M9 22V10h3.2l3.8 7 3.8-7H22v12h-2.6v-7.2L16 21.6h-1.2L11.6 14.8V22H9z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 242 B |
14
server/api/admin/audit.get.ts
Normal file
14
server/api/admin/audit.get.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { db } from '../../utils/db';
|
||||
import { requireAdmin } from '../../utils/auth';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event);
|
||||
const entries = await db.auditLog.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
include: { actor: true },
|
||||
});
|
||||
return {
|
||||
entries: entries.map((e) => ({ id: e.id, action: e.action, target: e.target, actor: e.actor?.email ?? 'system', createdAt: e.createdAt })),
|
||||
};
|
||||
});
|
||||
16
server/api/admin/stats.get.ts
Normal file
16
server/api/admin/stats.get.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { db } from '../../utils/db';
|
||||
import { requireAdmin } from '../../utils/auth';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event);
|
||||
const [totalUsers, activeSubs, recent] = await Promise.all([
|
||||
db.user.count(),
|
||||
db.subscription.count({ where: { status: 'active' } }),
|
||||
db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 5, include: { actor: true } }),
|
||||
]);
|
||||
return {
|
||||
totalUsers,
|
||||
activeSubs,
|
||||
recent: recent.map((e) => ({ id: e.id, action: e.action, target: e.target, actor: e.actor?.email ?? 'system', createdAt: e.createdAt })),
|
||||
};
|
||||
});
|
||||
13
server/api/admin/users/[id].delete.ts
Normal file
13
server/api/admin/users/[id].delete.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { db } from '../../../utils/db';
|
||||
import { requireAdmin } from '../../../utils/auth';
|
||||
import { recordAudit } from '../../../utils/audit';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const admin = await requireAdmin(event);
|
||||
const id = getRouterParam(event, 'id')!;
|
||||
if (id === admin.id) throw createError({ statusCode: 400, statusMessage: 'You cannot delete your own account.' });
|
||||
|
||||
const deleted = await db.user.delete({ where: { id } });
|
||||
await recordAudit({ actorId: admin.id, action: 'user.delete', target: deleted.email });
|
||||
return { ok: true };
|
||||
});
|
||||
20
server/api/admin/users/[id].patch.ts
Normal file
20
server/api/admin/users/[id].patch.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { z } from 'zod';
|
||||
import { db } from '../../../utils/db';
|
||||
import { requireAdmin } from '../../../utils/auth';
|
||||
import { recordAudit } from '../../../utils/audit';
|
||||
|
||||
const schema = z.object({ role: z.enum(['user', 'admin']) });
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const admin = await requireAdmin(event);
|
||||
const id = getRouterParam(event, 'id')!;
|
||||
const parsed = schema.safeParse(await readBody(event).catch(() => ({})));
|
||||
if (!parsed.success) throw createError({ statusCode: 400, statusMessage: 'Invalid role' });
|
||||
if (id === admin.id && parsed.data.role !== 'admin') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'You cannot remove your own admin role.' });
|
||||
}
|
||||
|
||||
const updated = await db.user.update({ where: { id }, data: { role: parsed.data.role } });
|
||||
await recordAudit({ actorId: admin.id, action: 'user.role', target: updated.email, meta: { role: parsed.data.role } });
|
||||
return { user: { id: updated.id, email: updated.email, role: updated.role } };
|
||||
});
|
||||
18
server/api/admin/users/index.get.ts
Normal file
18
server/api/admin/users/index.get.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { db } from '../../../utils/db';
|
||||
import { requireAdmin } from '../../../utils/auth';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event);
|
||||
const users = await db.user.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
subscription: { select: { plan: true, status: true } },
|
||||
},
|
||||
});
|
||||
return { users };
|
||||
});
|
||||
27
server/api/auth/login.post.ts
Normal file
27
server/api/auth/login.post.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { z } from 'zod';
|
||||
import { db } from '../../utils/db';
|
||||
import { createSession, verifyPassword } from '../../utils/auth';
|
||||
import { recordAudit } from '../../utils/audit';
|
||||
|
||||
const schema = z.object({ email: z.string().email(), password: z.string().min(1) });
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parsed = schema.safeParse(await readBody(event).catch(() => ({})));
|
||||
if (!parsed.success) throw createError({ statusCode: 400, statusMessage: 'Email and password are required.' });
|
||||
|
||||
const email = parsed.data.email.toLowerCase();
|
||||
const user = await db.user.findUnique({ where: { email } });
|
||||
if (!user || !(await verifyPassword(parsed.data.password, user.passwordHash))) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid email or password.' });
|
||||
}
|
||||
|
||||
await db.subscription.upsert({
|
||||
where: { userId: user.id },
|
||||
update: {},
|
||||
create: { userId: user.id, plan: 'free', status: 'active', provider: 'mock' },
|
||||
});
|
||||
await createSession(event, user.id);
|
||||
await recordAudit({ actorId: user.id, action: 'user.login', target: user.email });
|
||||
|
||||
return { user: { id: user.id, email: user.email, name: user.name, role: user.role } };
|
||||
});
|
||||
7
server/api/auth/logout.post.ts
Normal file
7
server/api/auth/logout.post.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { destroySession } from '../../utils/auth';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await destroySession(event);
|
||||
// 303 so a plain HTML form POST turns into a GET of the home page.
|
||||
return sendRedirect(event, '/', 303);
|
||||
});
|
||||
12
server/api/auth/me.get.ts
Normal file
12
server/api/auth/me.get.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { db } from '../../utils/db';
|
||||
import { getCurrentUser } from '../../utils/auth';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await getCurrentUser(event);
|
||||
if (!user) {
|
||||
setResponseStatus(event, 401);
|
||||
return { user: null, plan: 'free' };
|
||||
}
|
||||
const sub = await db.subscription.findUnique({ where: { userId: user.id } });
|
||||
return { user, plan: sub?.plan ?? 'free' };
|
||||
});
|
||||
43
server/api/auth/register.post.ts
Normal file
43
server/api/auth/register.post.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { z } from 'zod';
|
||||
import { db } from '../../utils/db';
|
||||
import { createSession, hashPassword } from '../../utils/auth';
|
||||
import { recordAudit } from '../../utils/audit';
|
||||
import { sendEmail } from '../../utils/email';
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email().max(200),
|
||||
password: z.string().min(8).max(200),
|
||||
name: z.string().max(120).optional(),
|
||||
});
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parsed = schema.safeParse(await readBody(event).catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid email or password (min 8 characters).' });
|
||||
}
|
||||
const email = parsed.data.email.toLowerCase();
|
||||
if (await db.user.findUnique({ where: { email } })) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'An account with this email already exists.' });
|
||||
}
|
||||
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: parsed.data.name || null,
|
||||
role: 'user',
|
||||
passwordHash: await hashPassword(parsed.data.password),
|
||||
subscription: { create: { plan: 'free', status: 'active', provider: 'mock' } },
|
||||
},
|
||||
});
|
||||
|
||||
await recordAudit({ actorId: user.id, action: 'user.register', target: user.email });
|
||||
await sendEmail(event, {
|
||||
to: user.email,
|
||||
subject: 'Welcome',
|
||||
html: '<p>Your account is ready. Sign in to get started.</p>',
|
||||
}).catch((e) => console.error('[email] register', e));
|
||||
|
||||
await createSession(event, user.id);
|
||||
setResponseStatus(event, 201);
|
||||
return { user: { id: user.id, email: user.email, name: user.name, role: user.role } };
|
||||
});
|
||||
39
server/api/billing/checkout.post.ts
Normal file
39
server/api/billing/checkout.post.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { z } from 'zod';
|
||||
import { db } from '../../utils/db';
|
||||
import { getCurrentUser } from '../../utils/auth';
|
||||
import { getPayments } from '../../utils/payments';
|
||||
import { recordAudit } from '../../utils/audit';
|
||||
|
||||
const schema = z.object({ plan: z.enum(['free', 'pro']) });
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await getCurrentUser(event);
|
||||
if (!user) throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
||||
|
||||
const parsed = schema.safeParse(await readBody(event).catch(() => ({})));
|
||||
if (!parsed.success) throw createError({ statusCode: 400, statusMessage: 'Invalid plan' });
|
||||
|
||||
const config = useRuntimeConfig(event);
|
||||
const siteUrl = String(config.public.siteUrl).replace(/\/$/, '');
|
||||
const provider = getPayments(event);
|
||||
const result = await provider.createCheckout({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
plan: parsed.data.plan,
|
||||
successUrl: `${siteUrl}/dashboard`,
|
||||
cancelUrl: `${siteUrl}/pricing`,
|
||||
});
|
||||
|
||||
// The mock provider has no external redirect: activate the subscription right away.
|
||||
if (provider.name === 'mock') {
|
||||
const currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
await db.subscription.upsert({
|
||||
where: { userId: user.id },
|
||||
update: { plan: parsed.data.plan, status: 'active', provider: 'mock', currentPeriodEnd },
|
||||
create: { userId: user.id, plan: parsed.data.plan, status: 'active', provider: 'mock', currentPeriodEnd },
|
||||
});
|
||||
}
|
||||
|
||||
await recordAudit({ actorId: user.id, action: 'billing.checkout', target: parsed.data.plan, meta: { provider: provider.name } });
|
||||
return { id: result.id, url: result.url, provider: provider.name };
|
||||
});
|
||||
32
server/api/billing/webhook.post.ts
Normal file
32
server/api/billing/webhook.post.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { db } from '../../utils/db';
|
||||
import { getPayments } from '../../utils/payments';
|
||||
import { recordAudit } from '../../utils/audit';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const raw = await readRawBody(event, 'utf8');
|
||||
const signature = getHeader(event, 'stripe-signature') ?? null;
|
||||
const provider = getPayments(event);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await provider.parseWebhook(raw ?? '', signature);
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 400, statusMessage: e instanceof Error ? e.message : 'Bad webhook' });
|
||||
}
|
||||
|
||||
if (parsed.type === 'checkout.completed' && parsed.userId) {
|
||||
const currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
await db.subscription.upsert({
|
||||
where: { userId: parsed.userId },
|
||||
update: { plan: parsed.plan ?? 'pro', status: 'active', provider: provider.name, currentPeriodEnd },
|
||||
create: { userId: parsed.userId, plan: parsed.plan ?? 'pro', status: 'active', provider: provider.name, currentPeriodEnd },
|
||||
});
|
||||
await recordAudit({ actorId: parsed.userId, action: 'billing.activated', target: parsed.plan ?? 'pro' });
|
||||
} else if (parsed.type === 'subscription.canceled' && parsed.userId) {
|
||||
await db.subscription.update({ where: { userId: parsed.userId }, data: { status: 'canceled' } }).catch(() => undefined);
|
||||
await recordAudit({ actorId: parsed.userId, action: 'billing.canceled', target: parsed.userId });
|
||||
}
|
||||
|
||||
// Idempotent: re-delivered events re-apply the same upsert.
|
||||
return { received: true };
|
||||
});
|
||||
5
server/api/health.get.ts
Normal file
5
server/api/health.get.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default defineEventHandler(() => ({
|
||||
status: 'ok',
|
||||
service: 'templates2-base-nuxt',
|
||||
time: new Date().toISOString(),
|
||||
}));
|
||||
17
server/api/me/usage.get.ts
Normal file
17
server/api/me/usage.get.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { db } from '../../utils/db';
|
||||
import { requireUser } from '../../utils/auth';
|
||||
import { getPlan } from '#shared/plans';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
const sub = await db.subscription.findUnique({ where: { userId: user.id } });
|
||||
const plan = getPlan(sub?.plan);
|
||||
const usedCalls = await db.auditLog.count({ where: { actorId: user.id } });
|
||||
return {
|
||||
plan: plan.id,
|
||||
metrics: [
|
||||
{ label: 'Seats', used: 1, limit: plan.limits.seats },
|
||||
{ label: 'API calls', used: usedCalls, limit: plan.limits.apiCalls },
|
||||
],
|
||||
};
|
||||
});
|
||||
12
server/middleware/guard.ts
Normal file
12
server/middleware/guard.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Server middleware: coarse auth gate for protected pages, before rendering.
|
||||
// Keep the cookie name in sync with server/utils/auth.ts.
|
||||
const SESSION_COOKIE = 'w4c_session';
|
||||
const PROTECTED = ['/dashboard', '/admin'];
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const path = getRequestURL(event).pathname;
|
||||
const isProtected = PROTECTED.some((p) => path === p || path.startsWith(`${p}/`));
|
||||
if (!isProtected) return;
|
||||
if (getCookie(event, SESSION_COOKIE)) return;
|
||||
return sendRedirect(event, `/login?next=${encodeURIComponent(path)}`, 302);
|
||||
});
|
||||
6
server/routes/robots.txt.ts
Normal file
6
server/routes/robots.txt.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig(event);
|
||||
const base = String(config.public.siteUrl).replace(/\/$/, '');
|
||||
setHeader(event, 'content-type', 'text/plain');
|
||||
return `User-agent: *\nAllow: /\nDisallow: /admin\nDisallow: /dashboard\nDisallow: /api\nSitemap: ${base}/sitemap.xml\n`;
|
||||
});
|
||||
11
server/routes/sitemap.xml.ts
Normal file
11
server/routes/sitemap.xml.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig(event);
|
||||
const base = String(config.public.siteUrl).replace(/\/$/, '');
|
||||
const now = new Date().toISOString();
|
||||
const urls = ['/', '/pricing', '/legal/privacy', '/legal/terms'];
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls
|
||||
.map((u) => ` <url><loc>${base}${u}</loc><lastmod>${now}</lastmod></url>`)
|
||||
.join('\n')}\n</urlset>\n`;
|
||||
setHeader(event, 'content-type', 'application/xml');
|
||||
return body;
|
||||
});
|
||||
14
server/utils/audit.ts
Normal file
14
server/utils/audit.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { db } from './db';
|
||||
|
||||
export type AuditInput = { actorId?: string | null; action: string; target?: string; meta?: unknown };
|
||||
|
||||
export async function recordAudit({ actorId, action, target, meta }: AuditInput): Promise<void> {
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actorId: actorId ?? null,
|
||||
action,
|
||||
target: target ?? null,
|
||||
meta: meta === undefined ? null : JSON.stringify(meta),
|
||||
},
|
||||
});
|
||||
}
|
||||
58
server/utils/auth.ts
Normal file
58
server/utils/auth.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { randomBytes } from 'node:crypto';
|
||||
import type { NitroEvent } from './h3-event';
|
||||
import { db } from './db';
|
||||
|
||||
export { hashPassword, verifyPassword } from '#shared/password';
|
||||
|
||||
export const SESSION_COOKIE = 'w4c_session';
|
||||
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export async function createSession(event: NitroEvent, userId: string): Promise<string> {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const expires = new Date(Date.now() + TTL_MS);
|
||||
await db.session.create({ data: { token, userId, expiresAt: expires } });
|
||||
setCookie(event, SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
expires,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function destroySession(event: NitroEvent): Promise<void> {
|
||||
const token = getCookie(event, SESSION_COOKIE);
|
||||
if (token) await db.session.deleteMany({ where: { token } });
|
||||
deleteCookie(event, SESSION_COOKIE, { path: '/' });
|
||||
}
|
||||
|
||||
export async function getCurrentUser(event: NitroEvent): Promise<SessionUser | null> {
|
||||
const token = getCookie(event, SESSION_COOKIE);
|
||||
if (!token) return null;
|
||||
const session = await db.session.findUnique({ where: { token }, include: { user: true } });
|
||||
if (!session || session.expiresAt < new Date()) return null;
|
||||
const { id, email, name, role } = session.user;
|
||||
return { id, email, name, role };
|
||||
}
|
||||
|
||||
/** API guard: 401 when unauthenticated. */
|
||||
export async function requireUser(event: NitroEvent): Promise<SessionUser> {
|
||||
const user = await getCurrentUser(event);
|
||||
if (!user) throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
||||
return user;
|
||||
}
|
||||
|
||||
/** API guard: 403 unless the admin role. */
|
||||
export async function requireAdmin(event: NitroEvent): Promise<SessionUser> {
|
||||
const user = await requireUser(event);
|
||||
if (user.role !== 'admin') throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
|
||||
return user;
|
||||
}
|
||||
24
server/utils/db.ts
Normal file
24
server/utils/db.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* The Prisma CLI resolves a relative SQLite path (`file:./dev.db`) against the schema directory
|
||||
* (`prisma/`), but inside a bundled Nitro output it would resolve against the copied client and
|
||||
* open an empty database. Resolve relative sqlite paths against `<cwd>/prisma` so the runtime and
|
||||
* the CLI always agree. Absolute paths and non-sqlite URLs are passed through unchanged.
|
||||
*/
|
||||
function resolveDatabaseUrl(): string | undefined {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url || !url.startsWith('file:')) return url;
|
||||
const file = url.slice('file:'.length);
|
||||
if (path.isAbsolute(file)) return url;
|
||||
return `file:${path.resolve(process.cwd(), 'prisma', file)}`;
|
||||
}
|
||||
|
||||
// A single PrismaClient per process.
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
export const db =
|
||||
globalForPrisma.prisma ?? new PrismaClient({ datasources: { db: { url: resolveDatabaseUrl() } } });
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
|
||||
44
server/utils/email.ts
Normal file
44
server/utils/email.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { NitroEvent } from './h3-event';
|
||||
|
||||
export type EmailMessage = { to: string; subject: string; html: string };
|
||||
export type EmailResult = { id: string; provider: string };
|
||||
|
||||
export interface EmailProvider {
|
||||
name: string;
|
||||
send(message: EmailMessage): Promise<EmailResult>;
|
||||
}
|
||||
|
||||
/** Default provider: no keys needed — logs the message. */
|
||||
class LogProvider implements EmailProvider {
|
||||
name = 'log';
|
||||
async send(message: EmailMessage): Promise<EmailResult> {
|
||||
console.log(`[email:log] to=${message.to} subject="${message.subject}"`);
|
||||
return { id: `log_${Date.now().toString(36)}`, provider: this.name };
|
||||
}
|
||||
}
|
||||
|
||||
/** Real provider, activated by RESEND_API_KEY. */
|
||||
class ResendProvider implements EmailProvider {
|
||||
name = 'resend';
|
||||
constructor(private apiKey: string, private from: string) {}
|
||||
async send(message: EmailMessage): Promise<EmailResult> {
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from: this.from, to: message.to, subject: message.subject, html: message.html }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Resend error ${res.status}: ${await res.text()}`);
|
||||
const data = (await res.json()) as { id: string };
|
||||
return { id: data.id, provider: this.name };
|
||||
}
|
||||
}
|
||||
|
||||
export function getEmail(event: NitroEvent): EmailProvider {
|
||||
const config = useRuntimeConfig(event);
|
||||
if (config.resendApiKey) return new ResendProvider(config.resendApiKey, config.emailFrom);
|
||||
return new LogProvider();
|
||||
}
|
||||
|
||||
export function sendEmail(event: NitroEvent, message: EmailMessage): Promise<EmailResult> {
|
||||
return getEmail(event).send(message);
|
||||
}
|
||||
4
server/utils/h3-event.ts
Normal file
4
server/utils/h3-event.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Two h3 copies exist in the tree (root + the one bundled by @nuxt/nitro-server), so importing
|
||||
// `H3Event` from 'h3' resolves the wrong one and type errors. Derive the event type from the
|
||||
// auto-imported h3 helpers instead — always the copy Nitro actually uses.
|
||||
export type NitroEvent = Parameters<typeof getCookie>[0];
|
||||
82
server/utils/payments.ts
Normal file
82
server/utils/payments.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import type { NitroEvent } from './h3-event';
|
||||
|
||||
export type CheckoutInput = { userId: string; email: string; plan: string; successUrl: string; cancelUrl: string };
|
||||
export type CheckoutResult = { id: string; url: string };
|
||||
export type WebhookEvent = { type: 'checkout.completed' | 'subscription.canceled' | 'ignored'; userId?: string; plan?: string };
|
||||
|
||||
export interface PaymentProvider {
|
||||
name: string;
|
||||
createCheckout(input: CheckoutInput): Promise<CheckoutResult>;
|
||||
parseWebhook(rawBody: string, signature: string | null): Promise<WebhookEvent>;
|
||||
}
|
||||
|
||||
/** Zero-config provider: the checkout route activates the subscription directly (no redirect). */
|
||||
class MockProvider implements PaymentProvider {
|
||||
name = 'mock';
|
||||
async createCheckout(input: CheckoutInput): Promise<CheckoutResult> {
|
||||
const id = `mock_${input.userId.slice(0, 8)}_${input.plan}_${Date.now().toString(36)}`;
|
||||
return { id, url: `${input.successUrl}?billing=success&provider=mock&plan=${encodeURIComponent(input.plan)}` };
|
||||
}
|
||||
async parseWebhook(rawBody: string): Promise<WebhookEvent> {
|
||||
try {
|
||||
const data = JSON.parse(rawBody) as { type?: string; userId?: string; plan?: string };
|
||||
if (!data.type) return { type: 'ignored' };
|
||||
return { type: data.type as WebhookEvent['type'], userId: data.userId, plan: data.plan };
|
||||
} catch {
|
||||
return { type: 'ignored' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stripe provider over the REST API (no SDK dependency). */
|
||||
class StripeProvider implements PaymentProvider {
|
||||
name = 'stripe';
|
||||
constructor(private secretKey: string, private webhookSecret: string) {}
|
||||
|
||||
async createCheckout(input: CheckoutInput): Promise<CheckoutResult> {
|
||||
const body = new URLSearchParams();
|
||||
body.set('mode', 'subscription');
|
||||
body.set('success_url', input.successUrl);
|
||||
body.set('cancel_url', input.cancelUrl);
|
||||
body.set('client_reference_id', input.userId);
|
||||
body.set('customer_email', input.email);
|
||||
body.set('line_items[0][price]', input.plan);
|
||||
body.set('line_items[0][quantity]', '1');
|
||||
const res = await fetch('https://api.stripe.com/v1/checkout/sessions', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${this.secretKey}`, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Stripe error ${res.status}: ${await res.text()}`);
|
||||
const data = (await res.json()) as { id: string; url: string };
|
||||
return { id: data.id, url: data.url };
|
||||
}
|
||||
|
||||
async parseWebhook(rawBody: string, signature: string | null): Promise<WebhookEvent> {
|
||||
if (!this.webhookSecret) throw new Error('STRIPE_WEBHOOK_SECRET is not configured');
|
||||
if (!verifyStripeSignature(rawBody, signature, this.webhookSecret)) return { type: 'ignored' };
|
||||
const event = JSON.parse(rawBody) as { type?: string; data?: { object?: { client_reference_id?: string } } };
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
return { type: 'checkout.completed', userId: event.data?.object?.client_reference_id };
|
||||
}
|
||||
if (event.type === 'customer.subscription.deleted') return { type: 'subscription.canceled' };
|
||||
return { type: 'ignored' };
|
||||
}
|
||||
}
|
||||
|
||||
function verifyStripeSignature(payload: string, header: string | null, secret: string): boolean {
|
||||
if (!header) return false;
|
||||
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=') as [string, string]));
|
||||
if (!parts.t || !parts.v1) return false;
|
||||
const expected = createHmac('sha256', secret).update(`${parts.t}.${payload}`).digest('hex');
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(parts.v1);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export function getPayments(event: NitroEvent): PaymentProvider {
|
||||
const config = useRuntimeConfig(event);
|
||||
if (config.stripeSecretKey) return new StripeProvider(config.stripeSecretKey, config.stripeWebhookSecret);
|
||||
return new MockProvider();
|
||||
}
|
||||
71
shared/en.json
Normal file
71
shared/en.json
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"brand": { "name": "Acme" },
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"pricing": "Pricing",
|
||||
"dashboard": "Dashboard",
|
||||
"admin": "Admin",
|
||||
"login": "Sign in",
|
||||
"register": "Create account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"home": {
|
||||
"title": "Ship your product, not your boilerplate",
|
||||
"subtitle": "A ready-to-run base app: authentication, roles, payments, admin and SEO — working on day one.",
|
||||
"cta": "Get started",
|
||||
"secondaryCta": "View pricing",
|
||||
"featuresTitle": "Everything included",
|
||||
"features": {
|
||||
"auth": { "title": "Auth & roles", "body": "Email + password, httpOnly sessions, role guards on every route." },
|
||||
"billing": { "title": "Payments", "body": "Checkout works with a mock provider and no keys; Stripe activates from env." },
|
||||
"admin": { "title": "Admin panel", "body": "A role-gated shell with a dashboard and CRUD primitives." },
|
||||
"seo": { "title": "SEO & a11y", "body": "Metadata, sitemap, robots and AA contrast out of the box." }
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Simple pricing",
|
||||
"subtitle": "Start free. Upgrade when you grow.",
|
||||
"perMonth": "/ month",
|
||||
"choose": "Choose plan",
|
||||
"current": "Current plan",
|
||||
"upgraded": "Your plan is now active."
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Sign in",
|
||||
"registerTitle": "Create your account",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"name": "Name",
|
||||
"submitLogin": "Sign in",
|
||||
"submitRegister": "Create account",
|
||||
"noAccount": "No account yet?",
|
||||
"hasAccount": "Already have an account?",
|
||||
"demoHint": "Demo: admin@example.com / user@example.com — password \"password\"."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welcome, {name}",
|
||||
"plan": "Your plan",
|
||||
"usage": "Usage"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin",
|
||||
"overview": "Overview",
|
||||
"users": "Users",
|
||||
"audit": "Audit log",
|
||||
"totalUsers": "Total users",
|
||||
"activeSubs": "Active subscriptions",
|
||||
"makeAdmin": "Make admin",
|
||||
"makeUser": "Make user",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading…",
|
||||
"empty": "Nothing here yet.",
|
||||
"error": "Something went wrong.",
|
||||
"retry": "Try again",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete"
|
||||
}
|
||||
}
|
||||
19
shared/i18n.ts
Normal file
19
shared/i18n.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import en from './en.json';
|
||||
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
export const messages = { en } as const;
|
||||
export type Locale = keyof typeof messages;
|
||||
|
||||
type Vars = Record<string, string | number>;
|
||||
|
||||
/** Tiny dictionary lookup: t('nav.pricing') with optional {name} interpolation. */
|
||||
export function t(key: string, vars?: Vars, locale: Locale = DEFAULT_LOCALE): string {
|
||||
const dict = messages[locale] as Record<string, unknown>;
|
||||
const value = key.split('.').reduce<unknown>((acc, part) => {
|
||||
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[part];
|
||||
return undefined;
|
||||
}, dict);
|
||||
if (typeof value !== 'string') return key;
|
||||
if (!vars) return value;
|
||||
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(vars[name] ?? `{${name}}`));
|
||||
}
|
||||
10
shared/password.ts
Normal file
10
shared/password.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
|
||||
/** Pure password helpers (shared by server routes and unit tests). */
|
||||
export function hashPassword(plain: string): Promise<string> {
|
||||
return bcrypt.hash(plain, 10);
|
||||
}
|
||||
|
||||
export function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(plain, hash);
|
||||
}
|
||||
58
shared/plans.ts
Normal file
58
shared/plans.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export type PlanId = 'free' | 'pro';
|
||||
|
||||
export type Plan = {
|
||||
id: PlanId;
|
||||
name: string;
|
||||
price: number;
|
||||
/** Monthly included usage per metric. */
|
||||
limits: { seats: number; apiCalls: number };
|
||||
features: string[];
|
||||
};
|
||||
|
||||
export const PLANS: Record<PlanId, Plan> = {
|
||||
free: {
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
price: 0,
|
||||
limits: { seats: 2, apiCalls: 1_000 },
|
||||
features: ['Up to 2 members', '1,000 API calls / month', 'Community support'],
|
||||
},
|
||||
pro: {
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
price: 29,
|
||||
limits: { seats: 25, apiCalls: 100_000 },
|
||||
features: ['Up to 25 members', '100,000 API calls / month', 'Priority support'],
|
||||
},
|
||||
};
|
||||
|
||||
export const PLAN_IDS: PlanId[] = ['free', 'pro'];
|
||||
|
||||
export function isPlanId(value: unknown): value is PlanId {
|
||||
return value === 'free' || value === 'pro';
|
||||
}
|
||||
|
||||
export function getPlan(value: unknown): Plan {
|
||||
return isPlanId(value) ? PLANS[value] : PLANS.free;
|
||||
}
|
||||
|
||||
export function withinLimit(plan: unknown, metric: keyof Plan['limits'], used: number): boolean {
|
||||
return used < getPlan(plan).limits[metric];
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number, currency = 'USD'): string {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
|
||||
}
|
||||
|
||||
export function formatDate(value: Date | string): string {
|
||||
const d = typeof value === 'string' ? new Date(value) : value;
|
||||
return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(d);
|
||||
}
|
||||
|
||||
export function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
6
shared/types.ts
Normal file
6
shared/types.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
};
|
||||
75
tests/e2e/smoke.mjs
Normal file
75
tests/e2e/smoke.mjs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* End-to-end smoke test — drives the running server over HTTP (no browser needed).
|
||||
* Start the app first (`npm start`), then: QA_BASE_URL=http://127.0.0.1:3000 node tests/e2e/smoke.mjs
|
||||
* The QA harness runs this automatically while the server is up.
|
||||
*/
|
||||
const BASE = (process.env.QA_BASE_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
|
||||
const jar = new Map();
|
||||
const failures = [];
|
||||
|
||||
function store(res) {
|
||||
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [];
|
||||
for (const raw of list) {
|
||||
const pair = raw.split(';')[0];
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) jar.set(pair.slice(0, eq), pair.slice(eq + 1));
|
||||
}
|
||||
}
|
||||
|
||||
async function req(path, { method = 'GET', body } = {}) {
|
||||
const headers = {};
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (jar.size) headers.cookie = [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
const res = await fetch(BASE + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual' });
|
||||
store(res);
|
||||
const json = await res.clone().json().catch(() => null);
|
||||
return { status: res.status, json };
|
||||
}
|
||||
|
||||
function check(name, cond, detail = '') {
|
||||
if (cond) console.log(` ok ${name}`);
|
||||
else { console.log(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); failures.push(name); }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const email = `smoke_${Date.now().toString(36)}@example.com`;
|
||||
|
||||
const health = await req('/api/health');
|
||||
check('health 200', health.status === 200 && health.json?.status === 'ok', `status=${health.status}`);
|
||||
|
||||
const reg = await req('/api/auth/register', { method: 'POST', body: { email, password: 'password123', name: 'Smoke' } });
|
||||
check('register 201', reg.status === 201, `status=${reg.status}`);
|
||||
const regMe = await req('/api/auth/me');
|
||||
check('session after register', regMe.status === 200 && regMe.json?.user?.email === email, `status=${regMe.status}`);
|
||||
|
||||
const newUserAdmin = await req('/api/admin/users');
|
||||
check('non-admin blocked from admin API', newUserAdmin.status === 403, `status=${newUserAdmin.status}`);
|
||||
|
||||
const mailProvider = (await req('/api/billing/checkout', { method: 'POST', body: { plan: 'pro' } }));
|
||||
check('mock checkout returns a url', mailProvider.status === 200 && String(mailProvider.json?.url).includes('billing=success'), `status=${mailProvider.status}`);
|
||||
|
||||
await req('/api/auth/logout', { method: 'POST' });
|
||||
const afterLogout = await req('/api/auth/me');
|
||||
check('logout clears session', afterLogout.status === 401, `status=${afterLogout.status}`);
|
||||
|
||||
const adminLogin = await req('/api/auth/login', { method: 'POST', body: { email: 'admin@example.com', password: 'password' } });
|
||||
check('seeded admin can sign in', adminLogin.status === 200 && adminLogin.json?.user?.role === 'admin', `status=${adminLogin.status}`);
|
||||
|
||||
const users = await req('/api/admin/users');
|
||||
check('admin sees users', users.status === 200 && Array.isArray(users.json?.users) && users.json.users.length >= 2, `status=${users.status}`);
|
||||
|
||||
const webhook = await req('/api/billing/webhook', { method: 'POST', body: { type: 'checkout.completed', userId: adminLogin.json?.user?.id, plan: 'pro' } });
|
||||
check('mock webhook accepted', webhook.status === 200, `status=${webhook.status}`);
|
||||
|
||||
await req('/api/auth/logout', { method: 'POST' });
|
||||
const userLogin = await req('/api/auth/login', { method: 'POST', body: { email: 'user@example.com', password: 'password' } });
|
||||
check('seeded user can sign in', userLogin.status === 200 && userLogin.json?.user?.role === 'user', `status=${userLogin.status}`);
|
||||
const forbidden = await req('/api/admin/users');
|
||||
check('member forbidden on admin API', forbidden.status === 403, `status=${forbidden.status}`);
|
||||
|
||||
console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nAll e2e checks passed');
|
||||
process.exit(failures.length ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch((e) => { console.error(e); process.exit(1); });
|
||||
19
tests/unit/auth.test.ts
Normal file
19
tests/unit/auth.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { hashPassword, verifyPassword } from '#shared/password';
|
||||
import { slugify } from '#shared/plans';
|
||||
|
||||
describe('password hashing', () => {
|
||||
it('round-trips a password', async () => {
|
||||
const hash = await hashPassword('password');
|
||||
expect(hash).not.toBe('password');
|
||||
expect(await verifyPassword('password', hash)).toBe(true);
|
||||
expect(await verifyPassword('wrong', hash)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', () => {
|
||||
it('produces a url-safe slug', () => {
|
||||
expect(slugify('Hello, World!')).toBe('hello-world');
|
||||
expect(slugify(' Already-slug ')).toBe('already-slug');
|
||||
});
|
||||
});
|
||||
22
tests/unit/plans.test.ts
Normal file
22
tests/unit/plans.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { getPlan, isPlanId, PLANS, withinLimit } from '#shared/plans';
|
||||
|
||||
describe('plans', () => {
|
||||
it('recognises valid plan ids only', () => {
|
||||
expect(isPlanId('free')).toBe(true);
|
||||
expect(isPlanId('pro')).toBe(true);
|
||||
expect(isPlanId('enterprise')).toBe(false);
|
||||
expect(isPlanId(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the free plan for unknown values', () => {
|
||||
expect(getPlan('nonsense').id).toBe('free');
|
||||
expect(getPlan('pro').id).toBe('pro');
|
||||
});
|
||||
|
||||
it('enforces usage limits inclusively below the cap', () => {
|
||||
expect(withinLimit('free', 'seats', PLANS.free.limits.seats - 1)).toBe(true);
|
||||
expect(withinLimit('free', 'seats', PLANS.free.limits.seats)).toBe(false);
|
||||
expect(withinLimit('pro', 'apiCalls', 50_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
12
vitest.config.ts
Normal file
12
vitest.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { '#shared': fileURLToPath(new URL('./shared', import.meta.url)) },
|
||||
},
|
||||
test: {
|
||||
include: ['tests/unit/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue