diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index a993fa3..d222541 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -1,4 +1,4 @@ -name: opencode +name: OpenCode Agent on: issue_comment: @@ -13,7 +13,12 @@ jobs: startsWith(github.event.comment.body, '/oc') || contains(github.event.comment.body, ' /opencode') || startsWith(github.event.comment.body, '/opencode') - ) && github.event.comment.author_association == 'OWNER' + ) + && + ( + github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' + ) runs-on: ubuntu-latest permissions: id-token: write @@ -25,6 +30,11 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Debug author association + run: | + echo "Comment body: ${{ github.event.comment.body }}" + echo "Author association: ${{ github.event.comment.author_association }}" + - name: Run opencode uses: sst/opencode/github@latest env: diff --git a/.opencode/command/do-pr.md b/.opencode/command/do-pr.md index 0c8ee72..5323f31 100644 --- a/.opencode/command/do-pr.md +++ b/.opencode/command/do-pr.md @@ -19,7 +19,7 @@ Required behavior (non-interactive flow) - Run the relevant automated tests immediately after implementing the change. Tests must be run and pass before committing. - If a change only affects unit tests, run the narrower set of packages to save time. - If tests fail, refine the code until tests pass. Do not proceed to committing that TODO item until its tests pass. - - Once tests pass, update the spec (check off corresponding item) and commit the change locally using a descriptive conventional commit message (example `feat(7): add backup script`). + - Once tests pass, update the spec (check off corresponding item) and commit the change locally using a descriptive conventional commit message (example `feat: add backup script`). - Use: `git add -A && git commit -m ": "` 3. After all task items for the current section are completed and committed locally: - Push the branch to the remote: diff --git a/AGENTS.md b/AGENTS.md index 7919ba4..d99f10e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ This is a request-driven marketplace that prioritizes relationships over profit. - **Use TypeScript strict mode** - **Don't modify git config** - **Always use pre-existing layouts** from `src/layouts/` for page structure consistency +- **Always use pre-existing component** from `src/components/` for UI consistency and less duplicate code ### Contribution Process diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f66ae5c..51560fd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,9 +10,9 @@ A trust-based, invite-only marketplace built with Astro, React, and Supabase. Us All infrastructure defined as code: -- **Supabase**: Database schema via SQL migrations, RLS policies in migrations +- **Supabase**: Database schema via declarative SQL files in `supabase/schemas/`, migrations auto-generated via `supabase db diff` - **Cloudflare**: Workers config in `wrangler.toml`, environment variables in `.dev.vars` -- **Benefits**: Version controlled, reproducible, reviewable, no manual dashboard configuration +- **Benefits**: Version controlled, reproducible, reviewable, no manual dashboard configuration, single source of truth ### 2. Local Development Parity @@ -55,9 +55,9 @@ Every feature should support both power users and those who rank low on the "tec - **Database**: Supabase (PostgreSQL) - **Authentication**: Supabase Auth (email/phone OTP via Twilio) -- **Storage**: Supabase Storage (single bucket) +- **Storage**: Supabase Storage (images bucket with structured folders: avatars/, items/, messages/) - **Real-time**: Supabase Realtime -- **API**: Supabase REST + PostgREST with RLS +- **API**: Supabase REST + PostgREST with RLS, Custom API routes with JWT authentication ### Deployment @@ -83,173 +83,7 @@ Every feature should support both power users and those who rank low on the "tec ## Database Schema -### user - -- id (uuid, pk) -- display_name (text) -- about (text) -- avatar_url (text) -- vendor_id (text, unique, nullable) -- alphanumeric + underscore/dash -- created_at (timestamp) -- invited_by (uuid, fk -> user.id) - -### contact_info - -- id (uuid, pk) -- user_id (uuid, fk -> user.id) -- contact_type (enum: email|phone) -- value (text) -- visibility (enum: hidden|connections-only|public) -- created_at (timestamp) - -### user_settings - -- id (uuid, pk) -- user_id (uuid, fk -> user.id) -- setting_key (text) -- setting_value (jsonb) -- created_at (timestamp) -- updated_at (timestamp) - -### category - -- id (uuid, pk) -- name (text) -- new, resale, service -- description (text) -- created_at (timestamp) - -### item - -- id (uuid, pk) -- user_id (uuid, fk -> user.id) -- type (enum: buy|sell) -- category_id (uuid, fk -> category.id) -- title (text) -- description (text) -- price_string (text) -- price or budget -- visibility (enum: hidden|connections-only|public) -- status (enum: active|archived|deleted) -- created_at (timestamp) -- updated_at (timestamp) - -### item_image - -- id (uuid, pk) -- item_id (uuid, fk -> item.id) -- url (text) -- alt_text (text) -- order_index (integer) -- created_at (timestamp) - -### watch - -- id (uuid, pk) -- name (text) -- query_params (text) -- notify (uuid, fk -> contact_info.id, nullable) - -### connection - -- id (uuid, pk) -- user_a (uuid, fk -> user.id) -- requester -- user_b (uuid, fk -> user.id) -- recipient -- status (enum: pending|accepted|declined) -- created_at (timestamp) -- unique(user_a, user_b) - -### thread - -- id (uuid, pk) -- item_id (uuid, fk -> item.id) -- creator_id (uuid, fk -> user.id) -- thread initiator -- responder_id (uuid, fk -> user.id) -- other participant -- created_at (timestamp) -- unique(item_id, creator_id, responder_id) - -### message - -- id (uuid, pk) -- thread_id (uuid, fk -> thread.id) -- sender_id (uuid, fk -> user.id) -- content (text) -- read (boolean) -- created_at (timestamp) - -### message_image - -- id (uuid, pk) -- message_id (uuid, fk -> message.id) -- url (text) -- order_index (integer) -- created_at (timestamp) - -### invite - -- id (uuid, pk) -- inviter_id (uuid, fk -> user.id) -- invite_code (text, unique) -- 8 alphanumeric characters -- used_by (uuid, fk -> user.id, nullable) -- used_at (timestamp, nullable) -- revoked_at (timestamp, nullable) -- created_at (timestamp) - -## Row Level Security (RLS) - -### user - -- Public profiles: All authenticated users -- Vendor profiles: Accessible via public routes (does not require authentication) - -### contact_info - -- Hidden: System only -- Connections Only: Direct connections only (status='accepted') -- Public: Anyone can view, even if not authenticated - -### user_settings - -- Read/Write: Owner only (user_id) - -### category - -- Public: All authenticated users (read-only) - -### item - -- Hidden: Creator only -- Connections Only: Creator + direct connections (status='accepted') -- Public: All authenticated users -- Buy items: Creator shown as "Anonymous" to non-connections - -### item_image - -- Follows parent item visibility rules -- Images inherit visibility from their item - -### connection - -- Read: Both parties (user_a or user_b) -- Write: user_a creates with status='pending', user_b updates status - -### thread - -- Read/write: Participants only (creator_id or responder_id) -- Thread creator identity follows item visibility rules - -### message - -- Read/write: Participants only (sender_id or recipient in thread) -- Message images inherit thread visibility - -### message_image - -- Follows parent message visibility rules -- Images inherit visibility from their message - -### invite - -- Read/Write: Inviter only (inviter_id) -- Read: Used by user (used_by) for validation +See [here](supabase/schemas) ## Key Flows @@ -296,12 +130,15 @@ Every feature should support both power users and those who rank low on the "tec ### Invite Generation 1. User clicks "Invite someone" -1. System checks last invite timestamp -1. If < 24 hours: shows limit message -1. If eligible: generates 8-character code -1. Creates invite record -1. Returns link: `/signup?code=CODE` -1. User can revoke anytime (sets revoked_at) +1. User enters invitee's full name +1. User confirms two requirements: + - "I have met this person in person multiple times and know them well" + - "I agree to allow this person to have access to my Contacts-Only information" +1. System checks rate limit via `can_create_invite()` database function (uses database time, excludes revoked invites) +1. If non-revoked invite exists within last 24 hours: shows limit message +1. If eligible: generates 8-character code (uppercase alphanumeric, excludes I/L/O/0/1) +1. Creates invite record with invitee name +1. User can copy/share code or revoke anytime (sets revoked_at) ## Site Structure @@ -344,54 +181,60 @@ Content: ## Project Structure +(Only important directories and files are shown for brevity) + ``` project-root/ -├── wrangler.toml # Cloudflare Workers config +├── wrangler.jsonc # Cloudflare Workers config ├── supabase/ │ ├── config.toml # Supabase configuration -│ ├── migrations/ -│ │ ├── 001_initial_schema.sql -│ │ ├── 002_rls_policies.sql -│ │ └── 003_indexes.sql -│ └── seed.sql # Test data +│ ├── schemas/ # Declarative database schemas (source of truth) +│ ├── migrations/ # Auto-generated from schemas via `supabase db diff` +│ │ └── *.sql +│ ├── seeds/ # Test data for local development +│ └── storage/ # Seed storage files +│ └── images/ +│ ├── avatars/ +│ ├── items/ +│ └── messages/ ├── src/ │ ├── pages/ # Astro routes +│ │ ├── api/ # API endpoints (JWT-authenticated) +│ │ │ └── invites/ +│ │ ├── auth/ │ │ ├── index.astro # Landing page -│ │ ├── about.astro # About page +│ │ ├── about.mdx # About page (MDX) +│ │ ├── content-policy.mdx # Content policy (MDX) │ │ ├── dashboard.astro # User dashboard -│ │ ├── items/ -│ │ │ ├── index.astro # Item listings -│ │ │ ├── [id].astro # Item details -│ │ │ └── new.astro # Create item -│ │ ├── vendors.astro # Vendor directory -│ │ ├── profile/[id].astro # User profiles -│ │ ├── messages/ -│ │ │ └── index.astro # Message threads -│ │ ├── signup.astro # Invite signup -│ │ ├── [vendor_id].astro # Vendor profile -│ │ └── v/[vendor_id].astro # Alt vendor route +│ │ └── invites.astro # Invite management │ ├── components/ │ │ ├── react/ # Interactive components -│ │ │ ├── ItemForm.tsx -│ │ │ ├── MessageThread.tsx -│ │ │ ├── ItemFeed.tsx -│ │ │ └── ConnectionsList.tsx │ │ └── astro/ # Static components -│ │ ├── Header.astro -│ │ └── ItemCard.astro │ ├── layouts/ -│ │ ├── BaseLayout.astro # Common wrapper -│ │ └── AuthLayout.astro # Auth wrapper -│ └── lib/ -│ ├── supabase.ts # Database client -│ ├── auth.ts # Auth utilities -│ └── utils/ # Helpers +│ │ ├── Layout.astro # Base layout +│ │ ├── PageLayoutWithBreadcrumbs.astro +│ │ └── ProseLayout.astro # MDX layout +│ ├── lib/ +│ │ ├── auth.ts # Auth utilities (createSupabaseWithJWT, etc.) +│ │ ├── database.types.ts # Generated TypeScript types +│ │ ├── globals.ts +│ │ ├── storage.ts +│ │ ├── themeManager.ts +│ │ └── themes.ts +│ ├── styles/ +│ │ ├── global.css +│ │ └── themes.css +│ └── middleware.ts # Route protection ├── tests/ -│ ├── unit/ # Unit tests -│ └── e2e/ # Integration tests +│ ├── unit/ # Unit tests (Vitest) +│ ├── e2e/ # E2E tests (Playwright) +│ └── setup.ts └── .github/ └── workflows/ - └── ci.yml # CI/CD pipeline + ├── ci.yml # CI/CD pipeline + ├── code-review.yml + ├── opencode.yml + └── preview-deploy.yaml ``` ## Security @@ -402,6 +245,7 @@ project-root/ - Session management via Supabase Auth - Protected routes via Astro middleware - Invite-only prevents open signups +- API routes use JWT bearer tokens with `createSupabaseWithJWT()` for RLS context ### Authorization @@ -432,7 +276,8 @@ project-root/ - Auto-compression on upload (balanced quality/size) - 5 images per item/message -- Single storage bucket (simpler for MVP) +- Single 'images' bucket with structured folders (avatars/, items/, messages/) +- Storage RLS policies enforce visibility rules matching parent entities ## Error Handling diff --git a/specs/000-mvp.md b/specs/000-mvp.md index 3c824a1..e1988fc 100644 --- a/specs/000-mvp.md +++ b/specs/000-mvp.md @@ -97,7 +97,7 @@ This document contains the basic roadmap for the minimal viable product (MVP). ### Authentication System -- [ ] **Setup Supabase email auth** +- [x] **Setup Supabase email auth** - [x] Configure Supabase project with email OTP - [x] Create initial user from Supabase dashboard - [x] Implement route protection middleware @@ -109,21 +109,46 @@ This document contains the basic roadmap for the minimal viable product (MVP). - [x] Update `INFRASTRUCTURE.md` with Supabase auth information - [x] Redirect from `/` to `/dashboard` for signed-in users - [x] Associate authenticated users with our user table in DB - - [ ] Test sign-on in preview environment -- [ ] **Create invite validation system** - - [ ] Implement invite code generation - - [ ] Add invite usage tracking +- [x] **Add invite codes page** + - [x] Add dedicated page for invite code + - [x] List status of user's invite codes + - [x] Allow user to create new invite codes + - [x] Use our Button component on invite page + - [x] Add new Card component + - [x] Include title property (always shown in h2) + - [x] Use it on the dashboard page + - [x] Use it in the invite manager + - [x] Use it in the core values component + - [x] Move "Quick Actions" to their own Card, above the "Account Information" card + - [x] Include revoked invites in the history section + - [x] Include clear warning about only inviting trusted people with which you have an in-person relationship + - [x] Require the user to input invitee's name + - [x] Save invitee's name with invite code in new column "name" + - [x] Require the user to check a box "I have met this person in person multiple times" + - [x] Require the user to check a box "I agree to allow this person to have access to my Contacts-Only information" + - [x] Do not allow user to create invite code without checking both boxes and entering the invitee's name - [ ] **Build signup flow** - - [ ] Create invite code validation page - - [ ] Implement email OTP verification - - [ ] Create user record with invited_by relationship - - [ ] Add initial contact info (default to hidden) + - [ ] To avoid leaking details about existing users, we will create new user for each new OTP sign up + - [ ] Add column to users table that tracks their auth user ID (not nullable) + - [ ] Add invite code entry page for users that have signed in but don't have a record in the users table yet + - [ ] Prompt them to enter invite code + - [ ] Do not allow them to view any other page in the frontend until their enter invite code + - [ ] Update login page with appropriate warning + - [ ] Use "Welcome" instead of "Welcome back" + - [ ] Remove message about needing an invite code to sign up + - [ ] We only create new record in the users table once a user has entered a valid invite code + - [ ] We will need to remove current trigger that automatically adds records to the users table + - [ ] Users that are not in the users table should not be allowed to create new items, threads, or connections (enforced by RLS) + - [ ] Once user inputs valid invite code, use Supabase edge function to set things up: + - [ ] Create user record with invited_by relationship + - [ ] Add new accepted connection between two users - [ ] Build basic onboarding flow - [ ] Contact visibility settings - - [ ] About information - - [ ] Avatar upload + - [ ] Display Name (default to value from invite code) + - [ ] Bio information + - [ ] For now - use https://www.dicebear.com/styles/initials/ to automatically generate and upload avatar for new user - [ ] **Setup Supabase SMS auth** - [ ] Configure Supabase project with SMS OTP @@ -200,6 +225,12 @@ This document contains the basic roadmap for the minimal viable product (MVP). - [ ] **Add error handling and user feedback** - [ ] Implement comprehensive error boundaries + - [ ] Consistent error styling + - [ ] Use error color scheme rather than hardcoded red color scheme + - [ ] Shared components for error alerts + - [ ] Login form + - [ ] Invite manager + - [ ] Where else? - [ ] Add loading states and progress indicators - [ ] Create user-friendly error messages - [ ] Add form validation feedback diff --git a/src/components/astro/Breadcrumbs.astro b/src/components/astro/Breadcrumbs.astro new file mode 100644 index 0000000..1500ec5 --- /dev/null +++ b/src/components/astro/Breadcrumbs.astro @@ -0,0 +1,49 @@ +--- +export interface BreadcrumbItem { + label: string; + href?: string; +} + +export interface Props { + items: BreadcrumbItem[]; + class?: string; +} + +const { items = [], class: className = '' } = Astro.props; +--- + +{ + items.length > 0 && ( + + ) +} diff --git a/src/components/astro/CoreValues.astro b/src/components/astro/CoreValues.astro index 6596b93..8d1752f 100644 --- a/src/components/astro/CoreValues.astro +++ b/src/components/astro/CoreValues.astro @@ -1,11 +1,9 @@ --- // Core Values component - reusable list of platform core values +import Card from '../react/Card'; --- -
-

Our Core Values

+
  • Humans are eternal, the things we buy and sell are not. @@ -17,4 +15,4 @@ Quality is worth far more than quantity.
-
+ diff --git a/src/components/astro/PageHeader.astro b/src/components/astro/PageHeader.astro new file mode 100644 index 0000000..d4c9ff8 --- /dev/null +++ b/src/components/astro/PageHeader.astro @@ -0,0 +1,27 @@ +--- +export interface Props { + title: string; + description?: string; + class?: string; +} + +const { title, description, class: className = '' } = Astro.props; +const hasAction = Astro.slots.has('action'); +--- + +
+
+

{title}

+ {description &&

{description}

} +
+ + { + hasAction && ( +
+ +
+ ) + } +
diff --git a/src/components/react/Button.tsx b/src/components/react/Button.tsx index e51cd44..122f9c4 100644 --- a/src/components/react/Button.tsx +++ b/src/components/react/Button.tsx @@ -4,7 +4,7 @@ import type { ReactNode, } from 'react'; -export type ButtonVariant = 'primary' | 'secondary' | 'neutral'; +export type ButtonVariant = 'primary' | 'secondary' | 'neutral' | 'danger'; type BaseProps = { variant?: ButtonVariant; @@ -32,6 +32,8 @@ const variantStyles: Record = { 'bg-secondary hover:bg-secondary-600 text-white focus:ring-secondary focus:ring-offset-surface-elevated', neutral: 'bg-surface-border hover:bg-surface-elevated text-neutral-200 focus:ring-neutral-500 focus:ring-offset-surface', + danger: + 'bg-error hover:bg-error-600 text-white focus:ring-error focus:ring-offset-surface', }; export default function Button(props: ButtonProps) { diff --git a/src/components/react/Card.tsx b/src/components/react/Card.tsx new file mode 100644 index 0000000..da3fd17 --- /dev/null +++ b/src/components/react/Card.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from 'react'; + +interface CardProps { + title: string; + children: ReactNode; + className?: string; +} + +export default function Card({ title, children, className = '' }: CardProps) { + return ( +
+

{title}

+ {children} +
+ ); +} diff --git a/src/components/react/InviteManager.tsx b/src/components/react/InviteManager.tsx new file mode 100644 index 0000000..604eab9 --- /dev/null +++ b/src/components/react/InviteManager.tsx @@ -0,0 +1,402 @@ +import React, { useState } from 'react'; +import Button from './Button'; +import Card from './Card'; +import type { Tables } from '../../lib/database.types'; +import { createSupabaseBrowserClient } from '../../lib/auth'; +import { platformName } from '../../lib/globals'; + +type Invite = Tables<'invite'>; + +interface InviteManagerProps { + initialInvites: Invite[]; +} + +export default function InviteManager({ initialInvites }: InviteManagerProps) { + const [invites, setInvites] = useState(initialInvites); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Form state + const [name, setName] = useState(''); + const [metInPerson, setMetInPerson] = useState(false); + const [allowContactAccess, setAllowContactAccess] = useState(false); + const [showForm, setShowForm] = useState(false); + + const activeInvites = invites.filter( + (invite) => !invite.used_at && !invite.revoked_at + ); + + const pastInvites = invites.filter( + (invite) => invite.used_at || invite.revoked_at + ); + + // Sort past invites by creation date desc + pastInvites.sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + + const createInvite = async () => { + setLoading(true); + setError(null); + + // Validate form + if (!name.trim()) { + setError("Please enter the invitee's name"); + setLoading(false); + return; + } + + if (!metInPerson) { + setError( + 'You must confirm that you have met this person in person multiple times' + ); + setLoading(false); + return; + } + + if (!allowContactAccess) { + setError( + 'You must agree to allow this person to have access to your Contacts-Only information' + ); + setLoading(false); + return; + } + + try { + const supabase = createSupabaseBrowserClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + throw new Error('You must be logged in to create invites'); + } + + const res = await fetch('/api/invites/create', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ name: name.trim() }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to create invite'); + } + + const newInvite = await res.json(); + setInvites((prev) => [newInvite, ...prev]); + + // Reset form + setName(''); + setMetInPerson(false); + setAllowContactAccess(false); + setShowForm(false); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError('An unexpected error occurred'); + } + } finally { + setLoading(false); + } + }; + + const revokeInvite = async (inviteId: string) => { + if (!confirm('Are you sure you want to revoke this invite code?')) return; + + try { + const supabase = createSupabaseBrowserClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + throw new Error('You must be logged in to revoke invites'); + } + + const res = await fetch('/api/invites/revoke', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ inviteId }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to revoke invite'); + } + + const updatedInvite = await res.json(); + setInvites((prev) => + prev.map((inv) => (inv.id === updatedInvite.id ? updatedInvite : inv)) + ); + } catch (err) { + if (err instanceof Error) { + alert(err.message); + } else { + alert('An unexpected error occurred'); + } + } + }; + + const copyCode = async (code: string) => { + try { + await navigator.clipboard.writeText(code); + alert('Invite code copied to clipboard!'); + } catch (err) { + console.error('Failed to copy invite code:', err); + alert('Failed to copy invite code. Please try again.'); + } + }; + + const shareCode = async (code: string) => { + if (navigator.share) { + try { + await navigator.share({ + title: `Join ${platformName}`, + text: `Join me on ${platformName}! Use my invite code: ${code}`, + url: window.location.origin + '/auth/login', + }); + } catch (err) { + console.error('Error sharing:', err); + } + } else { + await copyCode(code); + } + }; + + return ( +
+ +
+ {/* Trust Warning */} +
+
+
+

+ Important Trust Notice +

+

+ Only invite people you trust and have met in person multiple + times. This person will automatically be connected to you. +

+
+
+
+ + {!showForm ? ( + + ) : ( +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 bg-surface-base border border-surface-border rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + placeholder="Enter the person's full name" + required + /> +
+ +
+
+ setMetInPerson(e.target.checked)} + className="mt-1 h-4 w-4 text-primary-600 focus:ring-primary-500 border-surface-border rounded bg-surface-base" + required + /> + +
+ +
+ setAllowContactAccess(e.target.checked)} + className="mt-1 h-4 w-4 text-primary-600 focus:ring-primary-500 border-surface-border rounded bg-surface-base" + required + /> + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ )} +
+
+ + + {activeInvites.length === 0 ? ( +

No active invite codes.

+ ) : ( +
+ {activeInvites.map((invite) => ( +
+
+
+ {invite.invite_code} +
+
+ For: {invite.name} +
+
+ Created: {new Date(invite.created_at).toLocaleDateString()} +
+
+ +
+ + + +
+
+ ))} +
+ )} +
+ + + {pastInvites.length === 0 ? ( +

No past invites.

+ ) : ( +
+ + + + + + + + + + + {pastInvites.map((invite) => { + let status = 'Unknown'; + let date = invite.created_at; + + if (invite.revoked_at) { + status = 'Revoked'; + date = invite.revoked_at; + } else if (invite.used_at) { + status = 'Used'; + date = invite.used_at; + } + + return ( + + + + + + + ); + })} + +
CodeNameStatusDate
+ {invite.invite_code} + + {invite.name} + + + {status} + + + {new Date(date).toLocaleDateString()} +
+
+ )} +
+
+ ); +} diff --git a/src/layouts/PageLayoutWithBreadcrumbs.astro b/src/layouts/PageLayoutWithBreadcrumbs.astro new file mode 100644 index 0000000..32e6614 --- /dev/null +++ b/src/layouts/PageLayoutWithBreadcrumbs.astro @@ -0,0 +1,20 @@ +--- +import Layout, { type Props as LayoutProps } from './Layout.astro'; +import Breadcrumbs, { + type BreadcrumbItem, +} from '../components/astro/Breadcrumbs.astro'; + +interface Props extends LayoutProps { + breadcrumbs: BreadcrumbItem[]; +} + +const { breadcrumbs, ...layoutProps } = Astro.props; +--- + + +
+ + +
+ +
diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7ea9111..16c4a96 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -51,6 +51,29 @@ export function createSupabaseBrowserClient() { return createBrowserClient(supabaseUrl, supabaseAnonKey); } +/** + * Create a Supabase client for API routes with JWT token authentication. + * Sets the JWT as the global auth token for RLS policy evaluation. + */ +export function createSupabaseWithJWT(jwt: string) { + const client = createServerClient(supabaseUrl, supabaseAnonKey, { + cookies: { + getAll() { + return []; + }, + setAll() { + // No-op for JWT-based clients + }, + }, + global: { + headers: { + Authorization: `Bearer ${jwt}`, + }, + }, + }); + return client; +} + /** * Get the current session from a server-side Supabase client. */ diff --git a/src/lib/database.types.ts b/src/lib/database.types.ts new file mode 100644 index 0000000..ab4741e --- /dev/null +++ b/src/lib/database.types.ts @@ -0,0 +1,660 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +export type Database = { + graphql_public: { + Tables: { + [_ in never]: never; + }; + Views: { + [_ in never]: never; + }; + Functions: { + graphql: { + Args: { + extensions?: Json; + operationName?: string; + query?: string; + variables?: Json; + }; + Returns: Json; + }; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; + public: { + Tables: { + category: { + Row: { + created_at: string; + description: string | null; + id: string; + name: string; + }; + Insert: { + created_at?: string; + description?: string | null; + id: string; + name: string; + }; + Update: { + created_at?: string; + description?: string | null; + id?: string; + name?: string; + }; + Relationships: []; + }; + connection: { + Row: { + created_at: string; + id: string; + status: Database['public']['Enums']['connection_status']; + user_a: string; + user_b: string; + }; + Insert: { + created_at?: string; + id?: string; + status?: Database['public']['Enums']['connection_status']; + user_a: string; + user_b: string; + }; + Update: { + created_at?: string; + id?: string; + status?: Database['public']['Enums']['connection_status']; + user_a?: string; + user_b?: string; + }; + Relationships: [ + { + foreignKeyName: 'connection_user_a_fkey'; + columns: ['user_a']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'connection_user_b_fkey'; + columns: ['user_b']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + contact_info: { + Row: { + contact_type: Database['public']['Enums']['contact_type']; + created_at: string; + id: string; + user_id: string; + value: string; + visibility: Database['public']['Enums']['visibility']; + }; + Insert: { + contact_type: Database['public']['Enums']['contact_type']; + created_at?: string; + id?: string; + user_id: string; + value: string; + visibility?: Database['public']['Enums']['visibility']; + }; + Update: { + contact_type?: Database['public']['Enums']['contact_type']; + created_at?: string; + id?: string; + user_id?: string; + value?: string; + visibility?: Database['public']['Enums']['visibility']; + }; + Relationships: [ + { + foreignKeyName: 'contact_info_user_id_fkey'; + columns: ['user_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + invite: { + Row: { + created_at: string; + id: string; + invite_code: string; + inviter_id: string; + name: string; + revoked_at: string | null; + used_at: string | null; + used_by: string | null; + }; + Insert: { + created_at?: string; + id?: string; + invite_code: string; + inviter_id: string; + name?: string; + revoked_at?: string | null; + used_at?: string | null; + used_by?: string | null; + }; + Update: { + created_at?: string; + id?: string; + invite_code?: string; + inviter_id?: string; + name?: string; + revoked_at?: string | null; + used_at?: string | null; + used_by?: string | null; + }; + Relationships: [ + { + foreignKeyName: 'invite_inviter_id_fkey'; + columns: ['inviter_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'invite_used_by_fkey'; + columns: ['used_by']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + item: { + Row: { + category_id: string; + created_at: string; + description: string | null; + id: string; + price_string: string | null; + status: Database['public']['Enums']['item_status']; + title: string; + type: Database['public']['Enums']['item_type']; + updated_at: string; + user_id: string; + visibility: Database['public']['Enums']['visibility']; + }; + Insert: { + category_id: string; + created_at?: string; + description?: string | null; + id?: string; + price_string?: string | null; + status?: Database['public']['Enums']['item_status']; + title: string; + type: Database['public']['Enums']['item_type']; + updated_at?: string; + user_id: string; + visibility?: Database['public']['Enums']['visibility']; + }; + Update: { + category_id?: string; + created_at?: string; + description?: string | null; + id?: string; + price_string?: string | null; + status?: Database['public']['Enums']['item_status']; + title?: string; + type?: Database['public']['Enums']['item_type']; + updated_at?: string; + user_id?: string; + visibility?: Database['public']['Enums']['visibility']; + }; + Relationships: [ + { + foreignKeyName: 'item_category_id_fkey'; + columns: ['category_id']; + isOneToOne: false; + referencedRelation: 'category'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'item_user_id_fkey'; + columns: ['user_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + item_image: { + Row: { + alt_text: string | null; + created_at: string; + id: string; + item_id: string; + order_index: number; + url: string; + }; + Insert: { + alt_text?: string | null; + created_at?: string; + id?: string; + item_id: string; + order_index?: number; + url: string; + }; + Update: { + alt_text?: string | null; + created_at?: string; + id?: string; + item_id?: string; + order_index?: number; + url?: string; + }; + Relationships: [ + { + foreignKeyName: 'item_image_item_id_fkey'; + columns: ['item_id']; + isOneToOne: false; + referencedRelation: 'item'; + referencedColumns: ['id']; + }, + ]; + }; + message: { + Row: { + content: string; + created_at: string; + id: string; + read: boolean; + sender_id: string; + thread_id: string; + }; + Insert: { + content: string; + created_at?: string; + id?: string; + read?: boolean; + sender_id: string; + thread_id: string; + }; + Update: { + content?: string; + created_at?: string; + id?: string; + read?: boolean; + sender_id?: string; + thread_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'message_sender_id_fkey'; + columns: ['sender_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'message_thread_id_fkey'; + columns: ['thread_id']; + isOneToOne: false; + referencedRelation: 'thread'; + referencedColumns: ['id']; + }, + ]; + }; + message_image: { + Row: { + created_at: string; + id: string; + message_id: string; + order_index: number; + url: string; + }; + Insert: { + created_at?: string; + id?: string; + message_id: string; + order_index?: number; + url: string; + }; + Update: { + created_at?: string; + id?: string; + message_id?: string; + order_index?: number; + url?: string; + }; + Relationships: [ + { + foreignKeyName: 'message_image_message_id_fkey'; + columns: ['message_id']; + isOneToOne: false; + referencedRelation: 'message'; + referencedColumns: ['id']; + }, + ]; + }; + thread: { + Row: { + created_at: string; + creator_id: string; + id: string; + item_id: string; + responder_id: string; + }; + Insert: { + created_at?: string; + creator_id: string; + id?: string; + item_id: string; + responder_id: string; + }; + Update: { + created_at?: string; + creator_id?: string; + id?: string; + item_id?: string; + responder_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'thread_creator_id_fkey'; + columns: ['creator_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'thread_item_id_fkey'; + columns: ['item_id']; + isOneToOne: false; + referencedRelation: 'item'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'thread_responder_id_fkey'; + columns: ['responder_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + user: { + Row: { + about: string | null; + avatar_url: string | null; + created_at: string; + display_name: string; + id: string; + invited_by: string | null; + vendor_id: string | null; + }; + Insert: { + about?: string | null; + avatar_url?: string | null; + created_at?: string; + display_name: string; + id?: string; + invited_by?: string | null; + vendor_id?: string | null; + }; + Update: { + about?: string | null; + avatar_url?: string | null; + created_at?: string; + display_name?: string; + id?: string; + invited_by?: string | null; + vendor_id?: string | null; + }; + Relationships: [ + { + foreignKeyName: 'user_invited_by_fkey'; + columns: ['invited_by']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + user_settings: { + Row: { + created_at: string; + id: string; + setting_key: string; + setting_value: Json; + updated_at: string; + user_id: string; + }; + Insert: { + created_at?: string; + id?: string; + setting_key: string; + setting_value?: Json; + updated_at?: string; + user_id: string; + }; + Update: { + created_at?: string; + id?: string; + setting_key?: string; + setting_value?: Json; + updated_at?: string; + user_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'user_settings_user_id_fkey'; + columns: ['user_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + watch: { + Row: { + created_at: string; + id: string; + name: string; + notify: string | null; + query_params: string; + user_id: string; + }; + Insert: { + created_at?: string; + id?: string; + name: string; + notify?: string | null; + query_params: string; + user_id: string; + }; + Update: { + created_at?: string; + id?: string; + name?: string; + notify?: string | null; + query_params?: string; + user_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'watch_notify_fkey'; + columns: ['notify']; + isOneToOne: false; + referencedRelation: 'contact_info'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'watch_user_id_fkey'; + columns: ['user_id']; + isOneToOne: false; + referencedRelation: 'user'; + referencedColumns: ['id']; + }, + ]; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { + connection_status: 'pending' | 'accepted' | 'declined'; + contact_type: 'email' | 'phone'; + item_status: 'active' | 'archived' | 'deleted'; + item_type: 'buy' | 'sell'; + visibility: 'hidden' | 'connections-only' | 'public'; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; + +type DatabaseWithoutInternals = Omit; + +type DefaultSchema = DatabaseWithoutInternals[Extract< + keyof Database, + 'public' +>]; + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views']) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends { + Row: infer R; + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & + DefaultSchema['Views']) + ? (DefaultSchema['Tables'] & + DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R; + } + ? R + : never + : never; + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I; + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I; + } + ? I + : never + : never; + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U; + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Update: infer U; + } + ? U + : never + : never; + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema['Enums'] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] + ? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions] + : never; + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema['CompositeTypes'] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes'] + ? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions] + : never; + +export const Constants = { + graphql_public: { + Enums: {}, + }, + public: { + Enums: { + connection_status: ['pending', 'accepted', 'declined'], + contact_type: ['email', 'phone'], + item_status: ['active', 'archived', 'deleted'], + item_type: ['buy', 'sell'], + visibility: ['hidden', 'connections-only', 'public'], + }, + }, +} as const; diff --git a/src/pages/api/invites/create.ts b/src/pages/api/invites/create.ts new file mode 100644 index 0000000..acc00da --- /dev/null +++ b/src/pages/api/invites/create.ts @@ -0,0 +1,155 @@ +import type { APIRoute } from 'astro'; +import { createSupabaseWithJWT } from '../../../lib/auth'; + +export const POST: APIRoute = async ({ request }) => { + const authHeader = request.headers.get('Authorization'); + + // Extract token from Authorization header + const token = authHeader?.replace('Bearer ', ''); + + if (!token) { + console.error('[API] No token found in Authorization header'); + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + }); + } + + // Create a Supabase client with the JWT token for authentication and RLS context + const supabase = createSupabaseWithJWT(token); + + // Validate the token and get current user + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(token); + + if (userError) { + console.error('[API] Auth error:', userError.message); + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + }); + } + + if (!user) { + console.error('[API] No user found after authentication'); + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + }); + } + + // Check if user exists in user table + const { data: dbUser, error: dbUserError } = await supabase + .from('user') + .select('id, display_name') + .eq('id', user.id) + .single(); + + if (dbUserError) { + console.error( + '[API] Error checking user in database:', + dbUserError.message + ); + } + if (!dbUser) { + console.error('[API] User authenticated but not in user table!'); + return new Response( + JSON.stringify({ + error: 'User profile not found. Please complete signup first.', + }), + { status: 403 } + ); + } + + try { + const body = await request.json(); + const { name } = body; + + // Validate required fields + if (!name) { + return new Response( + JSON.stringify({ + error: 'Invite name is required', + }), + { + status: 400, + } + ); + } + + // Check if user can create an invite (uses database time for accuracy) + const { data: canCreate, error: rateLimitError } = await supabase.rpc( + 'can_create_invite', + { user_id: user.id } + ); + + if (rateLimitError) { + console.error('[API] Rate limit check error:', rateLimitError.message); + return new Response(JSON.stringify({ error: 'Database error' }), { + status: 500, + }); + } + + if (!canCreate) { + return new Response( + JSON.stringify({ + error: 'You can only generate one invite code every 24 hours.', + }), + { status: 429 } + ); + } + + // Generate random 8-char code with retries + // Using ambiguous-safe alphanumeric characters (excluding I, L, O, 0, 1) + const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + const maxRetries = 3; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + let code = ''; + for (let i = 0; i < 8; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + // Insert new invite + const { data: newInvite, error: insertError } = await supabase + .from('invite') + .insert({ + inviter_id: user.id, + invite_code: code, + name: name, + }) + .select() + .single(); + + if (insertError) { + console.error('[API] Insert error:', insertError.message); + + // Handle potential collision + if (insertError.code === '23505') { + // Unique violation, try again + continue; + } + // Other errors are fatal + return new Response(JSON.stringify({ error: insertError.message }), { + status: 500, + }); + } + + // Success + return new Response(JSON.stringify(newInvite), { + status: 201, + }); + } + + // If we exhausted all retries + return new Response( + JSON.stringify({ + error: 'Failed to generate a unique invite code. Please try again.', + }), + { status: 500 } + ); + } catch { + return new Response(JSON.stringify({ error: 'Invalid request' }), { + status: 400, + }); + } +}; diff --git a/src/pages/api/invites/revoke.ts b/src/pages/api/invites/revoke.ts new file mode 100644 index 0000000..55fa593 --- /dev/null +++ b/src/pages/api/invites/revoke.ts @@ -0,0 +1,74 @@ +import type { APIRoute } from 'astro'; +import { createSupabaseWithJWT } from '../../../lib/auth'; + +export const POST: APIRoute = async ({ request }) => { + const authHeader = request.headers.get('Authorization'); + + // Extract token from Authorization header + const token = authHeader?.replace('Bearer ', ''); + + if (!token) { + console.error('[API] No token found in Authorization header'); + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + }); + } + + // Create a Supabase client with the JWT token for authentication and RLS context + const supabase = createSupabaseWithJWT(token); + + // Validate the token and get current user + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(token); + + if (userError) { + console.error('[API] Auth error:', userError.message); + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + }); + } + + if (!user) { + console.error('[API] No user found after authentication'); + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + }); + } + + try { + const body = await request.json(); + const { inviteId } = body; + + if (!inviteId) { + return new Response(JSON.stringify({ error: 'Invite ID is required' }), { + status: 400, + }); + } + + // Update the invite to set revoked_at + // RLS policy "Users can update invites" ensures they can only update their own + const { data, error } = await supabase + .from('invite') + .update({ revoked_at: new Date().toISOString() }) + .eq('id', inviteId) + .eq('inviter_id', user.id) // Double check ownership though RLS covers it + .select() + .single(); + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + }); + } + + return new Response(JSON.stringify(data), { + status: 200, + }); + } catch { + return new Response(JSON.stringify({ error: 'Invalid request' }), { + status: 400, + }); + } +}; diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro index 36b8117..28cae62 100644 --- a/src/pages/dashboard.astro +++ b/src/pages/dashboard.astro @@ -1,6 +1,8 @@ --- import Layout from '../layouts/Layout.astro'; import LogoutButton from '../components/react/LogoutButton'; +import PageHeader from '../components/astro/PageHeader.astro'; +import Card from '../components/react/Card'; // User is set by middleware for protected routes const user = Astro.locals.user; @@ -16,58 +18,78 @@ const createdAt = user?.created_at --- -
-
-

Dashboard

- -
+ + + - + +
+
+
Email
+
{user?.email || 'Not set'}
+
+
+
User ID
+
+ {user?.id || 'Unknown'} +
+
+
+
Account Created
+
{createdAt}
+
+
+
Last Sign In
+
+ { + user?.last_sign_in_at + ? new Date(user.last_sign_in_at).toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }) + : 'Unknown' + } +
+
+
+
diff --git a/src/pages/invites.astro b/src/pages/invites.astro new file mode 100644 index 0000000..c5e25af --- /dev/null +++ b/src/pages/invites.astro @@ -0,0 +1,44 @@ +--- +import PageLayoutWithBreadcrumbs from '../layouts/PageLayoutWithBreadcrumbs.astro'; +import InviteManager from '../components/react/InviteManager'; +import PageHeader from '../components/astro/PageHeader.astro'; +import { createSupabaseServerClient } from '../lib/auth'; + +// Auth check handled by middleware, but we need user for data fetching +const user = Astro.locals.user; + +if (!user) { + return Astro.redirect('/auth/login'); +} + +const cookieHeader = Astro.request.headers.get('cookie'); + +const supabase = createSupabaseServerClient(Astro.cookies, cookieHeader); + +// Fetch user's invites +const { data: invites, error } = await supabase + .from('invite') + .select('*') + .eq('inviter_id', user.id) + .order('created_at', { ascending: false }); + +if (error) { + console.error('Error fetching invites:', error); +} +--- + + + + + + diff --git a/src/styles/themes.css b/src/styles/themes.css index 0d6b4d1..2753e9a 100644 --- a/src/styles/themes.css +++ b/src/styles/themes.css @@ -52,6 +52,21 @@ --color-neutral-800: oklch(var(--theme-neutral-800)); --color-neutral-900: oklch(var(--theme-neutral-900)); --color-neutral-950: oklch(var(--theme-neutral-950)); + --color-neutral: oklch(var(--theme-neutral-500)); + + /* Error color scale (OKLCH) */ + --color-error-50: oklch(var(--theme-error-50)); + --color-error-100: oklch(var(--theme-error-100)); + --color-error-200: oklch(var(--theme-error-200)); + --color-error-300: oklch(var(--theme-error-300)); + --color-error-400: oklch(var(--theme-error-400)); + --color-error-500: oklch(var(--theme-error-500)); + --color-error-600: oklch(var(--theme-error-600)); + --color-error-700: oklch(var(--theme-error-700)); + --color-error-800: oklch(var(--theme-error-800)); + --color-error-900: oklch(var(--theme-error-900)); + --color-error-950: oklch(var(--theme-error-950)); + --color-error: oklch(var(--theme-error-500)); /* Surface colors (OKLCH) */ --color-surface: oklch(var(--theme-surface)); @@ -104,6 +119,19 @@ --theme-surface: 0.158 0.025 250; /* very dark blue-gray */ --theme-surface-elevated: 0.219 0.028 250; /* elevated surface */ --theme-surface-border: 0.292 0.032 250; /* border */ + + /* Error palette - Crimson (OKLCH via tints.dev #7f1d1d) */ + --theme-error-50: 0.941 0.022 17.63; + --theme-error-100: 0.886 0.045 18.07; + --theme-error-200: 0.762 0.109 19.91; + --theme-error-300: 0.652 0.185 23.76; + --theme-error-400: 0.522 0.175 25.69; + --theme-error-500: 0.396 0.133 25.73; + --theme-error-600: 0.341 0.114 25.67; + --theme-error-700: 0.296 0.099 25.7; + --theme-error-800: 0.241 0.081 25.78; + --theme-error-900: 0.197 0.066 25.73; + --theme-error-950: 0.154 0.054 26.25; } /* Ember Theme - Orange and burnt orange on dark gray (Dark) */ @@ -151,6 +179,19 @@ --theme-surface: 0.168 0.002 0; /* very dark gray */ --theme-surface-elevated: 0.216 0.002 0; /* elevated surface */ --theme-surface-border: 0.292 0.002 0; /* border */ + + /* Error palette - Crimson (OKLCH via tints.dev #7f1d1d) */ + --theme-error-50: 0.941 0.022 17.63; + --theme-error-100: 0.886 0.045 18.07; + --theme-error-200: 0.762 0.109 19.91; + --theme-error-300: 0.652 0.185 23.76; + --theme-error-400: 0.522 0.175 25.69; + --theme-error-500: 0.396 0.133 25.73; + --theme-error-600: 0.341 0.114 25.67; + --theme-error-700: 0.296 0.099 25.7; + --theme-error-800: 0.241 0.081 25.78; + --theme-error-900: 0.197 0.066 25.73; + --theme-error-950: 0.154 0.054 26.25; } /* Ocean Theme - Blue and teal on white (Light) */ @@ -198,6 +239,19 @@ --theme-surface: 1 0 0; /* pure white */ --theme-surface-elevated: 0.985 0.002 247.8; /* very light gray */ --theme-surface-border: 0.916 0.005 250; /* light gray border */ + + /* Error palette - Claret (OKLCH via tints.dev #c41f1f) */ + --theme-error-50: 0.95 0.022 17.62; + --theme-error-100: 0.901 0.045 18.05; + --theme-error-200: 0.814 0.094 19.3; + --theme-error-300: 0.717 0.159 21.87; + --theme-error-400: 0.631 0.229 26.87; + --theme-error-500: 0.528 0.198 27.46; + --theme-error-600: 0.452 0.17 27.47; + --theme-error-700: 0.371 0.139 27.45; + --theme-error-800: 0.299 0.112 27.5; + --theme-error-900: 0.217 0.082 27.53; + --theme-error-950: 0.174 0.064 27.08; } /* Forest Theme - Green and tree bark brown on white (Light) */ @@ -245,4 +299,17 @@ --theme-surface: 1 0 0; /* pure white */ --theme-surface-elevated: 0.985 0.002 247.8; /* very light gray */ --theme-surface-border: 0.916 0.005 250; /* light gray border */ + + /* Error palette - Claret (OKLCH via tints.dev #c41f1f) */ + --theme-error-50: 0.95 0.022 17.62; + --theme-error-100: 0.901 0.045 18.05; + --theme-error-200: 0.814 0.094 19.3; + --theme-error-300: 0.717 0.159 21.87; + --theme-error-400: 0.631 0.229 26.87; + --theme-error-500: 0.528 0.198 27.46; + --theme-error-600: 0.452 0.17 27.47; + --theme-error-700: 0.371 0.139 27.45; + --theme-error-800: 0.299 0.112 27.5; + --theme-error-900: 0.217 0.082 27.53; + --theme-error-950: 0.174 0.064 27.08; } diff --git a/supabase/config.toml b/supabase/config.toml index 78e3be2..b3b0918 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -202,7 +202,7 @@ otp_expiry = 3600 # content_path = "./supabase/templates/invite.html" [auth.email.template.magic_link] -subject = "Your sign-in code" +subject = "One-Time Password" content_path = "./supabase/templates/magic_link.html" # Uncomment to customize notification email template diff --git a/supabase/migrations/20251119112252_update_category_id_type.sql b/supabase/migrations/20251119112252_update_category_id_type.sql index 319d5be..e85be67 100644 --- a/supabase/migrations/20251119112252_update_category_id_type.sql +++ b/supabase/migrations/20251119112252_update_category_id_type.sql @@ -1,5 +1,5 @@ -- Drop foreign key constraint first -alter table "public"."item" drop constraint if exists "item_category_id_fkey"; +alter table "public"."item" drop constraint "item_category_id_fkey"; -- Drop primary key constraint (this also drops the underlying index) alter table "public"."category" drop constraint if exists "category_pkey"; @@ -19,5 +19,6 @@ alter table "public"."category" add constraint "category_pkey" primary key ("id" CREATE INDEX idx_item_category_id ON public.item USING btree (category_id); -- Recreate foreign key constraint -alter table "public"."item" add constraint "item_category_id_fkey" - foreign key ("category_id") references "public"."category"("id") on delete set null; +alter table "public"."item" add constraint "item_category_id_fkey" FOREIGN KEY (category_id) REFERENCES public.category(id) not valid; + +alter table "public"."item" validate constraint "item_category_id_fkey"; diff --git a/supabase/migrations/20251121113622_add_invite_name.sql b/supabase/migrations/20251121113622_add_invite_name.sql new file mode 100644 index 0000000..2b0657f --- /dev/null +++ b/supabase/migrations/20251121113622_add_invite_name.sql @@ -0,0 +1 @@ +alter table "public"."invite" add column "name" text not null default ''::text; diff --git a/supabase/migrations/20251121123637_add_can_create_invite_function.sql b/supabase/migrations/20251121123637_add_can_create_invite_function.sql new file mode 100644 index 0000000..baf338b --- /dev/null +++ b/supabase/migrations/20251121123637_add_can_create_invite_function.sql @@ -0,0 +1,16 @@ +set check_function_bodies = off; + +CREATE OR REPLACE FUNCTION public.can_create_invite(user_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE SECURITY DEFINER +AS $function$ + select not exists ( + select 1 + from invite + where inviter_id = user_id + and revoked_at is null + and created_at > now() - interval '24 hours' + ); +$function$ +; diff --git a/supabase/schemas/08_invites.sql b/supabase/schemas/08_invites.sql index 9a0de4b..e4296b9 100644 --- a/supabase/schemas/08_invites.sql +++ b/supabase/schemas/08_invites.sql @@ -3,6 +3,7 @@ create table invite ( id uuid primary key default uuid_generate_v4(), inviter_id uuid not null references "user"(id) on delete cascade, + name text not null default '', invite_code text not null unique, used_by uuid references "user"(id), used_at timestamptz, @@ -41,3 +42,20 @@ create policy "Users can update invites" inviter_id = (select auth.uid()) or used_by = (select auth.uid()) ); + +-- Function to check if user has created a non-revoked invite in the last 24 hours +-- Returns true if user CAN create a new invite, false if rate limited +create or replace function can_create_invite(user_id uuid) +returns boolean +language sql +security definer +stable +as $$ + select not exists ( + select 1 + from invite + where inviter_id = user_id + and revoked_at is null + and created_at > now() - interval '24 hours' + ); +$$; diff --git a/supabase/schemas/11_storage.sql b/supabase/schemas/11_storage.sql new file mode 100644 index 0000000..1c5789c --- /dev/null +++ b/supabase/schemas/11_storage.sql @@ -0,0 +1,208 @@ +-- Storage bucket and RLS policies +-- This schema defines the 'images' storage bucket and access control policies +-- for avatars, item images, and message images + +-- Create the storage bucket if it doesn't exist +insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +values ( + 'images', + 'images', + false, + 5242880, -- 5MiB in bytes + array['image/jpeg', 'image/png', 'image/webp'] +) +on conflict (id) do nothing; + +-- ============================================================================= +-- AVATAR POLICIES +-- Path pattern: avatars/{user_id}/... +-- ============================================================================= + +-- Anyone can view avatars (needed for public vendor profiles and authenticated user browsing) +create policy "Anyone can view avatars" +on storage.objects for select +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'avatars' +); + +-- Users can upload their own avatar +create policy "Users can upload own avatar" +on storage.objects for insert +to authenticated +with check ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'avatars' and + (auth.uid())::text = (storage.foldername(name))[2] +); + +-- Users can update their own avatar +create policy "Users can update own avatar" +on storage.objects for update +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'avatars' and + (auth.uid())::text = (storage.foldername(name))[2] +) +with check ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'avatars' and + (auth.uid())::text = (storage.foldername(name))[2] +); + +-- Users can delete their own avatar +create policy "Users can delete own avatar" +on storage.objects for delete +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'avatars' and + (auth.uid())::text = (storage.foldername(name))[2] +); + +-- ============================================================================= +-- ITEM IMAGE POLICIES +-- Path pattern: items/{item_id}/... +-- Visibility follows the item's visibility setting +-- ============================================================================= + +-- Users can view item images based on item visibility +create policy "Users can view item images" +on storage.objects for select +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'items' and + exists ( + select 1 from public.item + where item.id::text = (storage.foldername(name))[2] + and ( + -- Owner can always view + item.user_id = auth.uid() + -- Public items (not deleted) + or (item.visibility = 'public' and item.status != 'deleted') + -- Connections-only items (not deleted) for connected users + or ( + item.visibility = 'connections-only' + and item.status != 'deleted' + and exists ( + select 1 from public.connection + where connection.status = 'accepted' + and ( + (connection.user_a = auth.uid() and connection.user_b = item.user_id) + or (connection.user_b = auth.uid() and connection.user_a = item.user_id) + ) + ) + ) + ) + ) +); + +-- Users can upload images for their own items +create policy "Users can upload item images" +on storage.objects for insert +to authenticated +with check ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'items' and + exists ( + select 1 from public.item + where item.id::text = (storage.foldername(name))[2] + and item.user_id = auth.uid() + ) +); + +-- Users can update images for their own items +create policy "Users can update item images" +on storage.objects for update +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'items' and + exists ( + select 1 from public.item + where item.id::text = (storage.foldername(name))[2] + and item.user_id = auth.uid() + ) +) +with check ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'items' and + exists ( + select 1 from public.item + where item.id::text = (storage.foldername(name))[2] + and item.user_id = auth.uid() + ) +); + +-- Users can delete images for their own items +create policy "Users can delete item images" +on storage.objects for delete +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'items' and + exists ( + select 1 from public.item + where item.id::text = (storage.foldername(name))[2] + and item.user_id = auth.uid() + ) +); + +-- ============================================================================= +-- MESSAGE IMAGE POLICIES +-- Path pattern: messages/{message_id}/... +-- Only thread participants can view, only sender can upload/delete +-- ============================================================================= + +-- Thread participants can view message images +create policy "Thread participants can view message images" +on storage.objects for select +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'messages' and + exists ( + select 1 from public.message + join public.thread on thread.id = message.thread_id + where message.id::text = (storage.foldername(name))[2] + and ( + thread.creator_id = auth.uid() + or thread.responder_id = auth.uid() + ) + ) +); + +-- Message senders can upload images (must be thread participant) +create policy "Message senders can upload images" +on storage.objects for insert +to authenticated +with check ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'messages' and + exists ( + select 1 from public.message + join public.thread on thread.id = message.thread_id + where message.id::text = (storage.foldername(name))[2] + and message.sender_id = auth.uid() + and ( + thread.creator_id = auth.uid() + or thread.responder_id = auth.uid() + ) + ) +); + +-- Message senders can delete their own images +create policy "Message senders can delete images" +on storage.objects for delete +to authenticated +using ( + bucket_id = 'images' and + (storage.foldername(name))[1] = 'messages' and + exists ( + select 1 from public.message + where message.id::text = (storage.foldername(name))[2] + and message.sender_id = auth.uid() + ) +); diff --git a/supabase/seeds/01_auth_users.sql b/supabase/seeds/01_auth_users.sql index 703900c..7f62158 100644 --- a/supabase/seeds/01_auth_users.sql +++ b/supabase/seeds/01_auth_users.sql @@ -27,7 +27,7 @@ insert into auth.users ( ( '22222222-2222-2222-2222-222222222201', '00000000-0000-0000-0000-000000000000', - 'alice@example.com', + 'alice.market@kwila.cloud', crypt('password123', gen_salt('bf')), '2025-10-20 10:00:00+00', '2025-10-20 10:00:00+00', @@ -45,7 +45,7 @@ insert into auth.users ( ( '22222222-2222-2222-2222-222222222202', '00000000-0000-0000-0000-000000000000', - 'bob@example.com', + 'bob.market@kwila.cloud', crypt('password123', gen_salt('bf')), '2025-10-25 14:30:00+00', '2025-10-25 14:30:00+00', @@ -63,7 +63,7 @@ insert into auth.users ( ( '22222222-2222-2222-2222-222222222203', '00000000-0000-0000-0000-000000000000', - 'carol@example.com', + 'carol.market@kwila.cloud', crypt('password123', gen_salt('bf')), '2025-10-30 09:15:00+00', '2025-10-30 09:15:00+00', @@ -81,7 +81,7 @@ insert into auth.users ( ( '22222222-2222-2222-2222-222222222204', '00000000-0000-0000-0000-000000000000', - 'david@example.com', + 'david.market@kwila.cloud', crypt('password123', gen_salt('bf')), '2025-11-04 11:00:00+00', '2025-11-04 11:00:00+00', @@ -99,7 +99,7 @@ insert into auth.users ( ( '22222222-2222-2222-2222-222222222205', '00000000-0000-0000-0000-000000000000', - 'eve@example.com', + 'eve.market@kwila.cloud', crypt('password123', gen_salt('bf')), '2025-11-09 16:45:00+00', '2025-11-09 16:45:00+00', @@ -133,7 +133,7 @@ insert into auth.identities ( '22222222-2222-2222-2222-222222222201', '22222222-2222-2222-2222-222222222201', 'email', - '{"sub": "22222222-2222-2222-2222-222222222201", "email": "alice@example.com", "email_verified": true, "phone_verified": false}', + '{"sub": "22222222-2222-2222-2222-222222222201", "email": "alice.market@kwila.cloud", "email_verified": true, "phone_verified": false}', '2025-10-20 10:00:00+00', '2025-10-20 10:00:00+00', '2025-10-20 10:00:00+00' @@ -144,7 +144,7 @@ insert into auth.identities ( '22222222-2222-2222-2222-222222222202', '22222222-2222-2222-2222-222222222202', 'email', - '{"sub": "22222222-2222-2222-2222-222222222202", "email": "bob@example.com", "email_verified": true, "phone_verified": false}', + '{"sub": "22222222-2222-2222-2222-222222222202", "email": "bob.market@kwila.cloud", "email_verified": true, "phone_verified": false}', '2025-10-25 14:30:00+00', '2025-10-25 14:30:00+00', '2025-10-25 14:30:00+00' @@ -155,7 +155,7 @@ insert into auth.identities ( '22222222-2222-2222-2222-222222222203', '22222222-2222-2222-2222-222222222203', 'email', - '{"sub": "22222222-2222-2222-2222-222222222203", "email": "carol@example.com", "email_verified": true, "phone_verified": false}', + '{"sub": "22222222-2222-2222-2222-222222222203", "email": "carol.market@kwila.cloud", "email_verified": true, "phone_verified": false}', '2025-10-30 09:15:00+00', '2025-10-30 09:15:00+00', '2025-10-30 09:15:00+00' @@ -166,7 +166,7 @@ insert into auth.identities ( '22222222-2222-2222-2222-222222222204', '22222222-2222-2222-2222-222222222204', 'email', - '{"sub": "22222222-2222-2222-2222-222222222204", "email": "david@example.com", "email_verified": true, "phone_verified": false}', + '{"sub": "22222222-2222-2222-2222-222222222204", "email": "david.market@kwila.cloud", "email_verified": true, "phone_verified": false}', '2025-11-04 11:00:00+00', '2025-11-04 11:00:00+00', '2025-11-04 11:00:00+00' @@ -177,7 +177,7 @@ insert into auth.identities ( '22222222-2222-2222-2222-222222222205', '22222222-2222-2222-2222-222222222205', 'email', - '{"sub": "22222222-2222-2222-2222-222222222205", "email": "eve@example.com", "email_verified": true, "phone_verified": false}', + '{"sub": "22222222-2222-2222-2222-222222222205", "email": "eve.market@kwila.cloud", "email_verified": true, "phone_verified": false}', '2025-11-09 16:45:00+00', '2025-11-09 16:45:00+00', '2025-11-09 16:45:00+00' diff --git a/supabase/seeds/05_connections_invites.sql b/supabase/seeds/05_connections_invites.sql index 0106451..b8db641 100644 --- a/supabase/seeds/05_connections_invites.sql +++ b/supabase/seeds/05_connections_invites.sql @@ -27,20 +27,20 @@ insert into connection (id, user_a, user_b, status, created_at) values on conflict (id) do nothing; -- Invite records -insert into invite (id, inviter_id, invite_code, used_by, used_at, created_at) values +insert into invite (id, inviter_id, name, invite_code, used_by, used_at, created_at) values -- Alice's used invites - ('66666666-6666-6666-6666-666666666601', '22222222-2222-2222-2222-222222222201', 'ALICE001', '22222222-2222-2222-2222-222222222202', '2025-10-25 14:30:00+00', '2025-10-24 10:00:00+00'), - ('66666666-6666-6666-6666-666666666602', '22222222-2222-2222-2222-222222222201', 'ALICE002', '22222222-2222-2222-2222-222222222203', '2025-10-30 09:15:00+00', '2025-10-29 08:00:00+00'), + ('66666666-6666-6666-6666-666666666601', '22222222-2222-2222-2222-222222222201', 'Bob', 'ALICE001', '22222222-2222-2222-2222-222222222202', '2025-10-25 14:30:00+00', '2025-10-24 10:00:00+00'), + ('66666666-6666-6666-6666-666666666602', '22222222-2222-2222-2222-222222222201', 'Carol', 'ALICE002', '22222222-2222-2222-2222-222222222203', '2025-10-30 09:15:00+00', '2025-10-29 08:00:00+00'), -- Bob's used invite - ('66666666-6666-6666-6666-666666666603', '22222222-2222-2222-2222-222222222202', 'BOB00001', '22222222-2222-2222-2222-222222222204', '2025-11-04 11:00:00+00', '2025-11-03 15:00:00+00'), + ('66666666-6666-6666-6666-666666666603', '22222222-2222-2222-2222-222222222202', 'David', 'BOB00001', '22222222-2222-2222-2222-222222222204', '2025-11-04 11:00:00+00', '2025-11-03 15:00:00+00'), -- Carol's used invite - ('66666666-6666-6666-6666-666666666604', '22222222-2222-2222-2222-222222222203', 'CAROL001', '22222222-2222-2222-2222-222222222205', '2025-11-09 16:45:00+00', '2025-11-08 12:00:00+00'), + ('66666666-6666-6666-6666-666666666604', '22222222-2222-2222-2222-222222222203', 'Eve', 'CAROL001', '22222222-2222-2222-2222-222222222205', '2025-11-09 16:45:00+00', '2025-11-08 12:00:00+00'), -- Unused invites for testing - ('66666666-6666-6666-6666-666666666605', '22222222-2222-2222-2222-222222222201', 'TESTCODE', null, null, '2025-11-17 10:00:00+00'), - ('66666666-6666-6666-6666-666666666606', '22222222-2222-2222-2222-222222222204', 'DAVID001', null, null, '2025-11-18 09:00:00+00') + ('66666666-6666-6666-6666-666666666605', '22222222-2222-2222-2222-222222222201', 'Test', 'TESTCODE', null, null, '2025-11-17 10:00:00+00'), + ('66666666-6666-6666-6666-666666666606', '22222222-2222-2222-2222-222222222204', 'James', 'DAVID001', null, null, '2025-11-18 09:00:00+00') on conflict (id) do nothing; -- Revoked invite example -insert into invite (id, inviter_id, invite_code, revoked_at, created_at) values - ('66666666-6666-6666-6666-666666666607', '22222222-2222-2222-2222-222222222202', 'REVOKED1', '2025-11-14 10:00:00+00', '2025-11-12 08:00:00+00') +insert into invite (id, inviter_id, name, invite_code, revoked_at, created_at) values + ('66666666-6666-6666-6666-666666666607', '22222222-2222-2222-2222-222222222202', 'Revoked', 'REVOKED1', '2025-11-14 10:00:00+00', '2025-11-12 08:00:00+00') on conflict (id) do nothing;