Compare commits

...

2 Commits

Author SHA1 Message Date
355f3c5dfa feat: implement basic auth workflow 2026-04-11 21:36:50 -04:00
652ac1e2af refactor: Implemented basic app structure 2026-04-10 19:12:26 -04:00
17 changed files with 2300 additions and 67 deletions

View File

@@ -0,0 +1,8 @@
Write a session handoff file for the current session.
Steps:
1. Read `templates/claude-templates.md` and find the Session Handoff template (Template 4). Use the Light Handoff if this is a small project (under 5 sessions), Full Handoff otherwise.
2. Fill in every field based on what was accomplished in this session. Be specific — include exact file paths for every output, exact numbers discovered, and conditional logic established.
3. Write the handoff to `./docs/summaries/handoff-[today's date]-[topic].md`.
4. If a previous handoff file exists in `./docs/summaries/`, move it to `./docs/archive/handoffs/`.
5. Tell me the file path of the new handoff and summarize what it contains.

View File

@@ -0,0 +1,13 @@
Process an input document into a structured source summary.
Steps:
1. Read `templates/claude-templates.md` and find the Source Document Summary template (Template 1). Use the Light Source Summary if this is a small project (under 5 sessions), Full Source Summary otherwise.
2. Read the document at: $ARGUMENTS
3. Extract all information into the template format. Pay special attention to:
- EXACT numbers — do not round or paraphrase
- Requirements in IF/THEN/BUT/EXCEPT format
- Decisions with rationale and rejected alternatives
- Open questions marked as OPEN, ASSUMED, or MISSING
4. Write the summary to `./docs/summaries/source-[filename].md`.
5. Move the original document to `./docs/archive/`.
6. Tell me: what was extracted, what's unclear, and what needs follow-up.

View File

@@ -0,0 +1,13 @@
Report on the current project state.
Steps:
1. Read `./docs/summaries/00-project-brief.md` for project context.
2. Find and read the latest `handoff-*.md` file in `./docs/summaries/` for current state.
3. List all files in `./docs/summaries/` to understand what's been processed.
4. Report:
- **Project:** name and type from the project brief
- **Current phase:** based on the project phase tracker
- **Last session:** what was accomplished (from the latest handoff)
- **Next steps:** what the next session should do (from the latest handoff)
- **Open questions:** anything unresolved
- **Summary file count:** how many files in docs/summaries/ (warn if approaching 15)

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -1,10 +1,10 @@
export default defineNuxtRouteMiddleware((to) => { export default defineNuxtRouteMiddleware((to) => {
const user = useSupabaseUser() const user = useSupabaseUser()
const publicRoutes = ['/login', '/signup', '/auth/callback'] const publicRoutes = ['/', '/login', '/signup', '/auth/callback']
if (publicRoutes.includes(to.path)) return if (publicRoutes.includes(to.path)) return
if (!user.value) { if (!user.value) {
return navigateTo('/login') return navigateTo('/')
} }
}) })

View File

@@ -1,5 +1,17 @@
<template> <template>
<IonPage> <IonPage>
<!-- Splash: unauthenticated -->
<template v-if="!user">
<IonContent class="splash-content">
<div class="splash-center">
<img src="/oysqn_logo.png" alt="OYS Borrow a Boat" class="splash-logo" />
<IonButton expand="block" router-link="/login" class="splash-btn">Log In</IonButton>
</div>
</IonContent>
</template>
<!-- Home: authenticated -->
<template v-else>
<IonHeader> <IonHeader>
<IonToolbar color="primary"> <IonToolbar color="primary">
<IonButtons slot="start"> <IonButtons slot="start">
@@ -11,12 +23,43 @@
<IonContent class="ion-padding"> <IonContent class="ion-padding">
<h2>Welcome to OYS Borrow a Boat</h2> <h2>Welcome to OYS Borrow a Boat</h2>
</IonContent> </IonContent>
</template>
</IonPage> </IonPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
IonButtons, IonMenuButton, IonButtons, IonMenuButton, IonButton,
} from '@ionic/vue' } from '@ionic/vue'
const user = useSupabaseUser()
definePageMeta({ layout: false })
</script> </script>
<style scoped>
.splash-content {
--background: var(--ion-color-light);
}
.splash-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 2rem;
gap: 2rem;
}
.splash-logo {
max-width: 280px;
width: 100%;
}
.splash-btn {
width: 100%;
max-width: 280px;
}
</style>

View File

@@ -2,17 +2,114 @@
<IonPage> <IonPage>
<IonHeader> <IonHeader>
<IonToolbar color="primary"> <IonToolbar color="primary">
<IonButtons slot="start">
<IonBackButton default-href="/" />
</IonButtons>
<IonTitle>Sign In</IonTitle> <IonTitle>Sign In</IonTitle>
</IonToolbar> </IonToolbar>
</IonHeader> </IonHeader>
<IonContent class="ion-padding"> <IonContent class="ion-padding">
<!-- TODO: Auth form --> <div class="login-form">
<template v-if="!sent">
<p class="ion-text-center">Enter your email address and we'll send you a sign-in link.</p>
<IonList>
<IonItem>
<IonLabel position="stacked">Email address</IonLabel>
<IonInput
v-model="email"
type="email"
placeholder="you@example.com"
autocomplete="email"
inputmode="email"
@keyup.enter="send"
/>
</IonItem>
</IonList>
<IonButton
expand="block"
class="ion-margin-top"
:disabled="!email || loading"
@click="send"
>
<IonSpinner v-if="loading" name="crescent" slot="start" />
Send Sign-In Link
</IonButton>
<p v-if="error" class="error-text ion-text-center">{{ error }}</p>
</template>
<template v-else>
<div class="sent-state ion-text-center">
<IonIcon :icon="mailOutline" class="sent-icon" />
<h2>Check your email</h2>
<p>A sign-in link was sent to <strong>{{ email }}</strong>. Open it on this device to sign in.</p>
<IonButton fill="outline" expand="block" class="ion-margin-top" @click="reset">
Use a different email
</IonButton>
</div>
</template>
</div>
</IonContent> </IonContent>
</IonPage> </IonPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from '@ionic/vue' import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
IonButtons, IonBackButton, IonButton, IonList, IonItem,
IonLabel, IonInput, IonSpinner, IonIcon,
} from '@ionic/vue'
import { mailOutline } from 'ionicons/icons'
import { useAuthStore } from '~/stores/auth'
definePageMeta({ layout: false }) definePageMeta({ layout: false })
const auth = useAuthStore()
const email = ref('')
const loading = ref(false)
const sent = ref(false)
const error = ref('')
async function send() {
if (!email.value || loading.value) return
loading.value = true
error.value = ''
try {
await auth.sendMagicLink(email.value.trim())
sent.value = true
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to send link. Please try again.'
} finally {
loading.value = false
}
}
function reset() {
sent.value = false
email.value = ''
error.value = ''
}
</script> </script>
<style scoped>
.login-form {
max-width: 400px;
margin: 2rem auto;
}
.sent-state {
margin-top: 4rem;
}
.sent-icon {
font-size: 4rem;
color: var(--ion-color-primary);
margin-bottom: 1rem;
}
.error-text {
color: var(--ion-color-danger);
font-size: 0.875rem;
margin-top: 0.5rem;
}
</style>

View File

@@ -1,7 +0,0 @@
import { IonicVue } from '@ionic/vue'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(IonicVue, {
mode: 'md', // Use Material Design style on all platforms for consistency
})
})

View File

@@ -0,0 +1,103 @@
# Session Handoff: Local Dev Setup & Ionic Module Migration
**Date:** 2026-03-26
**Session Duration:** ~1.5 hours
**Session Focus:** Initialize local Supabase DB, get app loading in browser, fix Ionic/Nuxt router integration using the official @nuxtjs/ionic module
**Context Usage at Handoff:** Medium-high
## What Was Accomplished
1. **Node upgraded to 22** — added `engines: { node: ">=22" }` to `package.json`, created `.nvmrc` with value `22`
2. **Local Supabase initialized** — schema applied via migration; DB running at `postgresql://postgres:postgres@127.0.0.1:54322/postgres`
3. **`.env` switched to local Supabase** — URL `http://127.0.0.1:54321`, publishable key `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH`
4. **Schema migration created**`supabase/migrations/20260325000000_initial_schema.sql` (reordered from `schema.sql`: all tables first, then RLS policies, then trigger — original order caused `members` not found error during `boats` policy creation)
5. **`yarn dev` running** — app responds HTTP 200 at `http://localhost:3000`
6. **Migrated to `@nuxtjs/ionic`** — replaced manual IonicVue plugin + navManager/viewStacks shims (250 lines) with the official Nuxt Ionic module
## Exact State of Work in Progress
- App loads and serves HTTP 200; browser rendering not fully verified (IonRouterOutlet may still need testing)
- No pages beyond skeleton `index.vue`, `login.vue`, `auth/callback.vue` — all page content is TODO
- Auth flow not implemented (`login.vue` is still a skeleton)
## Decisions Made This Session
- USE `@nuxtjs/ionic` module INSTEAD OF manual `@ionic/vue` plugin BECAUSE it is the idiomatic Nuxt+Ionic integration; provides `@ionic/vue-router` correctly (which supplies `navManager` and `viewStacks` that `IonRouterOutlet` requires) — STATUS: confirmed
- USE local Supabase via CLI INSTEAD OF remote project for development BECAUSE user decision — STATUS: confirmed
- DOCKER_HOST must be set to `unix:///run/user/1000/podman/podman.sock` for all `npx supabase` commands on this machine — STATUS: confirmed
- USE `supabase db reset` to apply schema changes locally — STATUS: confirmed
- `mode: 'md'` moved from plugin to `ionic.config` in `nuxt.config.ts` — STATUS: confirmed
## Key Numbers Generated or Discovered This Session
- Local Supabase API port: 54321
- Local Supabase DB port: 54322
- Local Supabase Studio port: 54323
- Local Supabase Mailpit port: 54324
- Migration file timestamp: 20260325000000
- 6 tables in schema (unchanged from prior session)
- `@nuxtjs/ionic` version installed: 1.0.2
## Files Created or Modified
| File Path | Action | Description |
|-----------|--------|-------------|
| `.nvmrc` | Created | Pins Node to version 22 |
| `package.json` | Modified | Added `engines: { node: ">=22" }`, added `@nuxtjs/ionic`, `@ionic/vue-router` |
| `supabase/migrations/20260325000000_initial_schema.sql` | Created | Initial schema migration (reordered from schema.sql) |
| `.env` | Modified | Switched from remote Supabase to local (URL + publishable key) |
| `nuxt.config.ts` | Modified | Added `@nuxtjs/ionic` module, `ionic: { css: { utilities: true }, config: { mode: 'md' } }`, removed manual Ionic CSS imports and CDN link, removed vite optimizeDeps for @ionic/vue |
| `app/plugins/ionic.client.ts` | Deleted | Replaced by @nuxtjs/ionic module |
## What the NEXT Session Should Do
1. **First**: Verify app renders correctly in browser (check for Vue/Ionic console errors, confirm IonRouterOutlet works)
2. **Then**: Implement `app/pages/login.vue` — OTP flow: email input → `sendOtp()` → token input → `verifyOtp()` → redirect to `/`
3. **Then**: Implement `app/pages/auth/callback.vue` — handle magic link redirect (Supabase sets session from URL hash)
4. **Then**: Test auth flow end-to-end: send OTP via Mailpit (http://127.0.0.1:54324), verify, confirm member row created in `members` table
5. **Then**: Implement `app/pages/index.vue` — home page shell showing boat list or welcome state
6. **Then**: Run `npx supabase gen types typescript --local > app/types/supabase.ts` to replace placeholder types and remove `as any` casts in stores
## Open Questions Requiring User Input
- [ ] Should `login.vue` support magic link (email link) in addition to OTP, or OTP-only? — impacts login page UI
- [ ] Scheduling refactor design still deferred — should it be designed before or after auth + boat pages?
## Assumptions That Need Validation
- ASSUMED: `@nuxtjs/ionic` module correctly wires `@ionic/vue-router` so `IonRouterOutlet` has `navManager` and `viewStacks` — validate by loading app in browser and checking for no inject warnings
- ASSUMED: `handle_new_user()` trigger fires on magic link / OTP first sign-in — validate by signing in a test user and checking `members` table
- ASSUMED: `useIonRouter()` composable (from `@nuxtjs/ionic` auto-imports) should be used instead of `useRouter()` — validate against module docs; update `app/stores/auth.ts` if needed
## What NOT to Re-Read
- `docs/archive/handoffs/handoff-2026-03-25-initial-setup.md` — superseded
- `docs/archive/handoffs/handoff-2026-03-25-project-scaffold.md` — superseded
## Files to Load Next Session
- `app/pages/login.vue` — primary implementation target
- `app/stores/auth.ts` — has `sendOtp()`, `verifyOtp()`, `sendMagicLink()` — needed for login page
- `app/pages/auth/callback.vue` — second implementation target
- `app/types/supabase.ts` — after regenerating with `--local` flag
## Dev Environment Reference
```
# Start local Supabase (already running, but if needed):
DOCKER_HOST=unix:///run/user/1000/podman/podman.sock npx supabase start
# Check status / get keys:
DOCKER_HOST=unix:///run/user/1000/podman/podman.sock npx supabase status
# Apply schema changes:
DOCKER_HOST=unix:///run/user/1000/podman/podman.sock npx supabase db reset
# Start app:
yarn dev
# Mailpit (view OTP emails):
http://127.0.0.1:54324
# Supabase Studio:
http://127.0.0.1:54323
```

5
ionic.config.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "oysqn.app",
"integrations": {},
"type": "vue"
}

View File

@@ -26,12 +26,18 @@ export default defineNuxtConfig({
ssr: false, ssr: false,
modules: [ modules: [
'@nuxtjs/ionic',
'@primevue/nuxt-module', '@primevue/nuxt-module',
'@pinia/nuxt', '@pinia/nuxt',
'@vite-pwa/nuxt', '@vite-pwa/nuxt',
'@nuxtjs/supabase', '@nuxtjs/supabase',
], ],
ionic: {
css: { utilities: true },
config: { mode: 'md' },
},
primevue: { primevue: {
options: { options: {
theme: { theme: {
@@ -46,28 +52,18 @@ export default defineNuxtConfig({
supabase: { supabase: {
redirectOptions: { redirectOptions: {
login: '/login', login: '/',
callback: '/auth/callback', callback: '/auth/callback',
exclude: ['/login', '/signup', '/auth/callback'], exclude: ['/', '/login', '/signup', '/auth/callback'],
}, },
}, },
app: { app: {
head: { head: {
meta: [{ name: 'theme-color', content: '#027be3' }], meta: [{ name: 'theme-color', content: '#027be3' }],
link: [
{ rel: 'stylesheet', href: 'https://unpkg.com/@ionic/core/css/ionic.bundle.css' },
],
}, },
}, },
css: [
'@ionic/vue/css/core.css',
'@ionic/vue/css/normalize.css',
'@ionic/vue/css/structure.css',
'@ionic/vue/css/typography.css',
],
pwa: { pwa: {
manifest: { manifest: {
name: 'OYS Borrow a Boat', name: 'OYS Borrow a Boat',
@@ -93,12 +89,6 @@ export default defineNuxtConfig({
}, },
}, },
vite: {
optimizeDeps: {
include: ['@ionic/vue'],
},
},
runtimeConfig: { runtimeConfig: {
public: { public: {
supabaseUrl: '', supabaseUrl: '',

View File

@@ -12,8 +12,13 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest"
}, },
"engines": {
"node": ">=22"
},
"dependencies": { "dependencies": {
"@ionic/vue": "^8.5.0", "@ionic/vue": "^8.5.0",
"@ionic/vue-router": "8.8.2",
"@nuxtjs/ionic": "^1.0.2",
"@nuxtjs/supabase": "^1.5.0", "@nuxtjs/supabase": "^1.5.0",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
"@primevue/nuxt-module": "^4.5.4", "@primevue/nuxt-module": "^4.5.4",
@@ -28,6 +33,7 @@
"devDependencies": { "devDependencies": {
"@vite-pwa/nuxt": "^1.1.1", "@vite-pwa/nuxt": "^1.1.1",
"sass-embedded": "^1.98.0", "sass-embedded": "^1.98.0",
"supabase": "^2.84.4",
"vitest": "^4.1.0", "vitest": "^4.1.0",
"vue-tsc": "^2.0.0" "vue-tsc": "^2.0.0"
} }

8
supabase/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local

396
supabase/config.toml Normal file
View File

@@ -0,0 +1,396 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "oysqn.app"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
# This feature is only available on the hosted platform.
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:<port>/auth/v1).
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
# [experimental.pgdelta]
# When enabled, pg-delta becomes the active engine for supported schema flows.
# enabled = false
# Directory under `supabase/` where declarative files are written.
# declarative_schema_path = "./declarative"
# JSON string passed through to pg-delta SQL formatting.
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"

View File

@@ -0,0 +1,202 @@
-- OYS Borrow a Boat — Supabase Schema
-- ============================================================
-- TABLES
-- ============================================================
create table public.boats (
id uuid primary key default gen_random_uuid(),
name text not null,
display_name text,
class text,
year integer,
img_src text,
icon_src text,
booking_available boolean not null default true,
required_certs text[] not null default '{}',
max_passengers integer not null default 6,
defects jsonb not null default '[]',
-- defects shape: [{ type: string, severity: string, description: string, detail?: string }]
created_at timestamptz not null default now()
);
create table public.members (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
first_name text not null default '',
last_name text not null default '',
email text not null,
slack_id text,
certifications text[] not null default '{}',
-- cert codes match boats.required_certs values (e.g. 'j27', 'capri25')
role text not null default 'member'
check (role in ('member', 'skipper', 'admin', 'boatswain', 'volunteer', 'instructor')),
created_at timestamptz not null default now(),
unique(user_id)
);
create table public.interval_templates (
id uuid primary key default gen_random_uuid(),
name text not null,
time_tuples jsonb not null default '[]',
-- shape: [[startHHMM, endHHMM], ...] e.g. [["08:00","12:00"],["13:00","17:00"]]
created_at timestamptz not null default now()
);
create table public.intervals (
id uuid primary key default gen_random_uuid(),
boat_id uuid not null references public.boats(id) on delete cascade,
start_time timestamptz not null,
end_time timestamptz not null,
user_id uuid references auth.users(id) on delete set null,
created_at timestamptz not null default now()
);
create index intervals_boat_id_idx on public.intervals(boat_id);
create index intervals_time_range_idx on public.intervals(start_time, end_time);
create table public.reservations (
id uuid primary key default gen_random_uuid(),
boat_id uuid not null references public.boats(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
start_time timestamptz not null,
end_time timestamptz not null,
status text not null default 'pending'
check (status in ('pending', 'tentative', 'confirmed')),
reason text not null default '',
comment text not null default '',
member_ids text[] not null default '{}',
guest_ids text[] not null default '{}',
created_at timestamptz not null default now()
);
create index reservations_boat_id_idx on public.reservations(boat_id);
create index reservations_user_id_idx on public.reservations(user_id);
create index reservations_time_range_idx on public.reservations(start_time, end_time);
create table public.reference_docs (
id uuid primary key default gen_random_uuid(),
title text not null,
category text not null,
tags text[] not null default '{}',
subtitle text,
content text not null,
created_at timestamptz not null default now()
);
-- ============================================================
-- RLS
-- ============================================================
alter table public.boats enable row level security;
alter table public.members enable row level security;
alter table public.interval_templates enable row level security;
alter table public.intervals enable row level security;
alter table public.reservations enable row level security;
alter table public.reference_docs enable row level security;
-- boats
create policy "Authenticated users can read boats" on public.boats
for select using (auth.role() = 'authenticated');
create policy "Admins can manage boats" on public.boats
for all using (
exists (
select 1 from public.members
where user_id = auth.uid() and role in ('admin', 'boatswain')
)
);
-- members
create policy "Users can read own member record" on public.members
for select using (user_id = auth.uid());
create policy "Admins can read all members" on public.members
for select using (
exists (
select 1 from public.members m2
where m2.user_id = auth.uid() and m2.role in ('admin', 'boatswain', 'instructor')
)
);
create policy "Users can update own member record" on public.members
for update using (user_id = auth.uid());
create policy "Admins can manage all members" on public.members
for all using (
exists (
select 1 from public.members m2
where m2.user_id = auth.uid() and m2.role = 'admin'
)
);
-- interval_templates
create policy "Authenticated users can read interval templates" on public.interval_templates
for select using (auth.role() = 'authenticated');
create policy "Admins can manage interval templates" on public.interval_templates
for all using (
exists (
select 1 from public.members
where user_id = auth.uid() and role in ('admin', 'boatswain')
)
);
-- intervals
create policy "Authenticated users can read intervals" on public.intervals
for select using (auth.role() = 'authenticated');
create policy "Admins can manage intervals" on public.intervals
for all using (
exists (
select 1 from public.members
where user_id = auth.uid() and role in ('admin', 'boatswain')
)
);
-- reservations
create policy "Users can read own reservations" on public.reservations
for select using (user_id = auth.uid());
create policy "Admins can read all reservations" on public.reservations
for select using (
exists (
select 1 from public.members
where user_id = auth.uid() and role in ('admin', 'boatswain')
)
);
create policy "Authenticated users can read non-private reservation slots" on public.reservations
for select using (auth.role() = 'authenticated');
create policy "Users can create own reservations" on public.reservations
for insert with check (user_id = auth.uid());
create policy "Users can update own reservations" on public.reservations
for update using (user_id = auth.uid());
create policy "Admins can manage all reservations" on public.reservations
for all using (
exists (
select 1 from public.members
where user_id = auth.uid() and role in ('admin', 'boatswain')
)
);
-- reference_docs
create policy "Authenticated users can read reference docs" on public.reference_docs
for select using (auth.role() = 'authenticated');
create policy "Admins can manage reference docs" on public.reference_docs
for all using (
exists (
select 1 from public.members
where user_id = auth.uid() and role = 'admin'
)
);
-- ============================================================
-- TRIGGER: create member record on first sign-in
-- ============================================================
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer as $$
begin
insert into public.members (user_id, email)
values (new.id, new.email)
on conflict (user_id) do nothing;
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();

1407
yarn.lock

File diff suppressed because it is too large Load Diff