1487 lines
40 KiB
Markdown
1487 lines
40 KiB
Markdown
# Supabase + .NET + React To-Do App Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build a working minimal per-user to-do list where a React (Vite+TS) SPA authenticates against Supabase (email + password) and does all CRUD through an ASP.NET Core Web API that validates Supabase JWTs and talks directly to Supabase Postgres.
|
|
|
|
**Architecture:** React SPA uses `@supabase/supabase-js` only for auth; every `/api/todos*` call goes to the .NET backend with `Authorization: Bearer <access_token>`. The backend validates JWTs against Supabase's OpenID discovery document (which itself points at the JWKS), extracts the `sub` claim, and uses it as `user_id` in every query against a single `public.todos` table. Row Level Security stays off — .NET is the only writer.
|
|
|
|
**Tech Stack:** ASP.NET Core 9.0 Web API, EF Core 9 with Npgsql, React 18 + TypeScript, Vite 5, `@supabase/supabase-js` v2.
|
|
|
|
**Spec:** [`docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md`](../specs/2026-08-15-supabase-todo-app-design.md)
|
|
|
|
**Note on testing:** The spec deliberately excludes automated tests in this first pass. Each task uses **manual verification** (curl for backend endpoints, the browser for frontend UI) instead. Every task ends with a commit.
|
|
|
|
---
|
|
|
|
## Task 1: Repo baseline (.gitignore + README skeleton)
|
|
|
|
**Files:**
|
|
- Create: `.gitignore`
|
|
- Create: `README.md` (overwrites the placeholder from the initial commit)
|
|
|
|
- [ ] **Step 1: Write `.gitignore` covering .NET, Node, and local env files**
|
|
|
|
Create `.gitignore`:
|
|
|
|
```gitignore
|
|
# .NET
|
|
bin/
|
|
obj/
|
|
*.user
|
|
*.suo
|
|
.vs/
|
|
|
|
# ASP.NET local secrets
|
|
backend/appsettings.Development.json
|
|
backend/appsettings.Local.json
|
|
|
|
# Node
|
|
node_modules/
|
|
dist/
|
|
.vite/
|
|
npm-debug.log*
|
|
yarn-debug.log*
|
|
yarn-error.log*
|
|
|
|
# Editor / OS
|
|
.idea/
|
|
.vscode/
|
|
.DS_Store
|
|
|
|
# Frontend env
|
|
frontend/.env
|
|
frontend/.env.local
|
|
frontend/.env.*.local
|
|
```
|
|
|
|
- [ ] **Step 2: Write `README.md` skeleton**
|
|
|
|
Overwrite `README.md`:
|
|
|
|
```markdown
|
|
# supabase_test
|
|
|
|
Minimal to-do list. React (Vite+TypeScript) frontend, ASP.NET Core Web API backend, Supabase for auth and Postgres.
|
|
|
|
See [`docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md`](docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md) for the design.
|
|
|
|
Setup instructions land here after the app is wired up.
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add .gitignore README.md
|
|
git commit -m "Add gitignore and README skeleton"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Database migration for `todos` table
|
|
|
|
**Files:**
|
|
- Create: `migrations/001_create_todos.sql`
|
|
|
|
- [ ] **Step 1: Write the migration SQL**
|
|
|
|
Create `migrations/001_create_todos.sql`:
|
|
|
|
```sql
|
|
create table if not exists public.todos (
|
|
id bigserial primary key,
|
|
user_id uuid not null,
|
|
title text not null check (length(title) between 1 and 500),
|
|
completed boolean not null default false,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
create index if not exists todos_user_id_created_at_idx
|
|
on public.todos (user_id, created_at desc);
|
|
```
|
|
|
|
- [ ] **Step 2: Apply the migration to Supabase**
|
|
|
|
The engineer running this plan needs to apply the SQL against the Supabase project. Two options:
|
|
|
|
**Option A — Supabase SQL editor (no local tooling needed):**
|
|
1. Open the Supabase dashboard → **SQL Editor** → **New query**.
|
|
2. Paste the contents of `migrations/001_create_todos.sql`.
|
|
3. Click **Run**. Expect "Success. No rows returned."
|
|
|
|
**Option B — `psql` locally:**
|
|
```bash
|
|
sudo apt-get install -y postgresql-client
|
|
PGPASSWORD='<db-password>' psql \
|
|
"host=db.jrbqfctqhjttxobtoqts.supabase.co port=5432 dbname=postgres user=postgres sslmode=require" \
|
|
-f migrations/001_create_todos.sql
|
|
```
|
|
Expected: `CREATE TABLE` then `CREATE INDEX`.
|
|
|
|
- [ ] **Step 3: Verify the table exists**
|
|
|
|
Run this one-liner in the SQL editor (or via `psql -c`):
|
|
|
|
```sql
|
|
select column_name, data_type from information_schema.columns
|
|
where table_schema = 'public' and table_name = 'todos'
|
|
order by ordinal_position;
|
|
```
|
|
|
|
Expected columns: `id (bigint)`, `user_id (uuid)`, `title (text)`, `completed (boolean)`, `created_at (timestamp with time zone)`.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add migrations/001_create_todos.sql
|
|
git commit -m "Add todos table migration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Scaffold the ASP.NET Core Web API
|
|
|
|
**Files:**
|
|
- Create: `backend/backend.csproj`
|
|
- Create: `backend/Program.cs`
|
|
- Create: `backend/appsettings.json`
|
|
- Create: `backend/appsettings.Development.example.json`
|
|
- Create: `backend/appsettings.Development.json` (gitignored)
|
|
- Create: `backend/Properties/launchSettings.json`
|
|
|
|
- [ ] **Step 1: Create the project**
|
|
|
|
From the repo root:
|
|
|
|
```bash
|
|
dotnet new webapi --name backend --framework net9.0 --no-https --use-controllers --output backend
|
|
```
|
|
|
|
`--no-https` keeps local dev on plain `http://localhost:5000` (matches CORS config in Task 7). `--use-controllers` gives us `Controllers/`-based routing instead of minimal APIs.
|
|
|
|
Delete the sample files the template generates:
|
|
|
|
```bash
|
|
rm -f backend/Controllers/WeatherForecastController.cs backend/WeatherForecast.cs
|
|
```
|
|
|
|
If `--use-controllers` is rejected on your SDK version, run without it and then create `backend/Controllers/` manually — the rest of the plan uses controller classes regardless.
|
|
|
|
- [ ] **Step 2: Verify the scaffold builds**
|
|
|
|
```bash
|
|
cd backend
|
|
dotnet build
|
|
```
|
|
|
|
Expected: `Build succeeded. 0 Warning(s). 0 Error(s).`
|
|
|
|
- [ ] **Step 3: Configure ports and env in `Properties/launchSettings.json`**
|
|
|
|
Replace `backend/Properties/launchSettings.json` with:
|
|
|
|
```json
|
|
{
|
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
|
"profiles": {
|
|
"backend": {
|
|
"commandName": "Project",
|
|
"launchBrowser": false,
|
|
"applicationUrl": "http://localhost:5000",
|
|
"environmentVariables": {
|
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create `appsettings.Development.example.json` (committed template)**
|
|
|
|
The connection string uses Supabase's **session pooler** (Supavisor), not the direct DB host. Rationale: the direct host (`db.<ref>.supabase.co`) is IPv6-only for new Supabase projects; the pooler is IPv4-reachable and is Supabase's recommended entry point. Session mode (port `5432`) keeps prepared statements working with EF Core; transaction mode (`6543`) would break them.
|
|
|
|
Pooler URL shape:
|
|
```
|
|
Host=aws-<n>-<region>.pooler.supabase.com;Port=5432;Username=postgres.<project-ref>;Password=…
|
|
```
|
|
Get the exact `n` and `region` from your Supabase dashboard → Project Settings → Database → Connection string → "Session pooler" tab.
|
|
|
|
Create `backend/appsettings.Development.example.json`:
|
|
|
|
```json
|
|
{
|
|
"Logging": {
|
|
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
|
|
},
|
|
"ConnectionStrings": {
|
|
"Postgres": "Host=aws-N-REGION.pooler.supabase.com;Port=5432;Database=postgres;Username=postgres.YOURPROJECTREF;Password=REPLACE_ME;SSL Mode=Require;Trust Server Certificate=true"
|
|
},
|
|
"Supabase": {
|
|
"MetadataAddress": "https://YOURPROJECTREF.supabase.co/auth/v1/.well-known/openid-configuration",
|
|
"Issuer": "https://YOURPROJECTREF.supabase.co/auth/v1",
|
|
"Audience": "authenticated"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create `appsettings.Development.json` (gitignored, real values)**
|
|
|
|
Create `backend/appsettings.Development.json`. Substitute the DB password provided out of band. Do **not** paste the DB password into any committed file:
|
|
|
|
```json
|
|
{
|
|
"Logging": {
|
|
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
|
|
},
|
|
"ConnectionStrings": {
|
|
"Postgres": "Host=aws-1-eu-west-1.pooler.supabase.com;Port=5432;Database=postgres;Username=postgres.jrbqfctqhjttxobtoqts;Password=REPLACE_WITH_DB_PASSWORD;SSL Mode=Require;Trust Server Certificate=true"
|
|
},
|
|
"Supabase": {
|
|
"MetadataAddress": "https://jrbqfctqhjttxobtoqts.supabase.co/auth/v1/.well-known/openid-configuration",
|
|
"Issuer": "https://jrbqfctqhjttxobtoqts.supabase.co/auth/v1",
|
|
"Audience": "authenticated"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Trim `appsettings.json`** (leave only shared, non-secret defaults)
|
|
|
|
Replace `backend/appsettings.json`:
|
|
|
|
```json
|
|
{
|
|
"Logging": {
|
|
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
|
|
},
|
|
"AllowedHosts": "*"
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Verify the project still builds and runs**
|
|
|
|
```bash
|
|
cd backend
|
|
dotnet build
|
|
dotnet run &
|
|
sleep 3
|
|
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/openapi/v1.json # 200 if webapi template's OpenAPI is on
|
|
kill %1
|
|
```
|
|
|
|
The exact status may be 200 or 404 depending on template; the important check is that the server started without an unhandled exception.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add backend/ .gitignore # .gitignore already covers appsettings.Development.json
|
|
git commit -m "Scaffold ASP.NET Core Web API backend"
|
|
```
|
|
|
|
Confirm `git status` does NOT list `backend/appsettings.Development.json`.
|
|
|
|
---
|
|
|
|
## Task 4: EF Core + Npgsql + `Todo` entity + `AppDbContext`
|
|
|
|
**Files:**
|
|
- Modify: `backend/backend.csproj` (add EF Core + Npgsql packages)
|
|
- Create: `backend/Models/Todo.cs`
|
|
- Create: `backend/Data/AppDbContext.cs`
|
|
- Modify: `backend/Program.cs` (register DbContext)
|
|
|
|
- [ ] **Step 1: Add EF Core + Npgsql packages**
|
|
|
|
```bash
|
|
cd backend
|
|
dotnet add package Microsoft.EntityFrameworkCore --version 9.*
|
|
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 9.*
|
|
```
|
|
|
|
`--version 9.*` pins to the 9.x major line. Without it, `dotnet add package` picks the absolute-latest stable (e.g. 10.x once released) which will not resolve against `net9.0` — `NU1202` error.
|
|
|
|
- [ ] **Step 2: Create the `Todo` entity**
|
|
|
|
Create `backend/Models/Todo.cs`:
|
|
|
|
```csharp
|
|
namespace Backend.Models;
|
|
|
|
public class Todo
|
|
{
|
|
public long Id { get; set; }
|
|
public Guid UserId { get; set; }
|
|
public string Title { get; set; } = string.Empty;
|
|
public bool Completed { get; set; }
|
|
public DateTimeOffset CreatedAt { get; set; }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create `AppDbContext`**
|
|
|
|
Create `backend/Data/AppDbContext.cs`:
|
|
|
|
```csharp
|
|
using Backend.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Backend.Data;
|
|
|
|
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
|
{
|
|
public DbSet<Todo> Todos => Set<Todo>();
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
modelBuilder.Entity<Todo>(e =>
|
|
{
|
|
e.ToTable("todos", schema: "public");
|
|
e.HasKey(x => x.Id);
|
|
e.Property(x => x.Id).HasColumnName("id").ValueGeneratedOnAdd();
|
|
e.Property(x => x.UserId).HasColumnName("user_id").IsRequired();
|
|
e.Property(x => x.Title).HasColumnName("title").IsRequired().HasMaxLength(500);
|
|
e.Property(x => x.Completed).HasColumnName("completed").IsRequired();
|
|
e.Property(x => x.CreatedAt).HasColumnName("created_at").IsRequired();
|
|
e.HasIndex(x => new { x.UserId, x.CreatedAt })
|
|
.HasDatabaseName("todos_user_id_created_at_idx");
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Replace `Program.cs` with a known-good baseline**
|
|
|
|
Overwrite `backend/Program.cs` entirely (the template output varies between SDK versions, so we replace it wholesale). Marker comments here are load-bearing — later tasks replace them with real code:
|
|
|
|
```csharp
|
|
using Backend.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Services.AddDbContext<AppDbContext>(options =>
|
|
options.UseNpgsql(builder.Configuration.GetConnectionString("Postgres")));
|
|
|
|
// MARKER: CORS SERVICES
|
|
|
|
// MARKER: AUTH SERVICES
|
|
|
|
builder.Services.AddControllers();
|
|
|
|
var app = builder.Build();
|
|
|
|
// MARKER: CORS MIDDLEWARE
|
|
|
|
// MARKER: AUTH MIDDLEWARE
|
|
|
|
app.MapControllers();
|
|
|
|
app.Run();
|
|
```
|
|
|
|
- [ ] **Step 5: Verify build + startup does not throw when connecting**
|
|
|
|
```bash
|
|
cd backend
|
|
dotnet build
|
|
dotnet run &
|
|
sleep 4
|
|
kill %1 2>/dev/null
|
|
```
|
|
|
|
Expected: no exception in the startup log about Postgres. DbContext is registered but nothing queries it yet, so a connection isn't actually opened at startup.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add backend/
|
|
git commit -m "Add EF Core, Npgsql, Todo entity, and AppDbContext"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: JWT authentication (JWKS via OpenID metadata)
|
|
|
|
**Files:**
|
|
- Modify: `backend/backend.csproj` (add JwtBearer package)
|
|
- Modify: `backend/Program.cs` (add auth services + middleware)
|
|
|
|
- [ ] **Step 1: Add the JwtBearer package**
|
|
|
|
```bash
|
|
cd backend
|
|
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 9.*
|
|
```
|
|
|
|
Same reason as Task 4: pin the major to avoid picking a version incompatible with `net9.0`.
|
|
|
|
- [ ] **Step 2: Wire authentication in `Program.cs`**
|
|
|
|
Open `backend/Program.cs`. Add these two `using` directives at the top (below the existing `using` lines):
|
|
|
|
```csharp
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
```
|
|
|
|
Replace the line `// MARKER: AUTH SERVICES` with:
|
|
|
|
```csharp
|
|
builder.Services
|
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(options =>
|
|
{
|
|
options.MetadataAddress = builder.Configuration["Supabase:MetadataAddress"]
|
|
?? throw new InvalidOperationException("Supabase:MetadataAddress not configured");
|
|
options.RequireHttpsMetadata = true;
|
|
options.MapInboundClaims = false;
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidIssuer = builder.Configuration["Supabase:Issuer"],
|
|
ValidateAudience = true,
|
|
ValidAudience = builder.Configuration["Supabase:Audience"],
|
|
ValidateIssuerSigningKey = true,
|
|
ValidateLifetime = true,
|
|
ClockSkew = TimeSpan.FromSeconds(30),
|
|
NameClaimType = "sub"
|
|
};
|
|
});
|
|
|
|
builder.Services.AddAuthorization();
|
|
```
|
|
|
|
Replace the line `// MARKER: AUTH MIDDLEWARE` with:
|
|
|
|
```csharp
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
```
|
|
|
|
- [ ] **Step 3: Verify startup fetches the JWKS**
|
|
|
|
```bash
|
|
cd backend
|
|
dotnet run &
|
|
sleep 5
|
|
kill %1 2>/dev/null
|
|
```
|
|
|
|
Expected: no exceptions. The JwtBearer middleware defers metadata fetching until first request, so absence of errors is enough.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add backend/
|
|
git commit -m "Configure JWT bearer auth against Supabase JWKS via OpenID metadata"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: TodosController — GET, POST, PATCH, DELETE
|
|
|
|
**Files:**
|
|
- Create: `backend/Controllers/TodosController.cs`
|
|
- Create: `backend/Dtos/TodoDto.cs`
|
|
- Create: `backend/Dtos/CreateTodoRequest.cs`
|
|
- Create: `backend/Dtos/UpdateTodoRequest.cs`
|
|
|
|
- [ ] **Step 1: Create DTOs**
|
|
|
|
Create `backend/Dtos/TodoDto.cs`:
|
|
|
|
```csharp
|
|
namespace Backend.Dtos;
|
|
|
|
public record TodoDto(long Id, string Title, bool Completed, DateTimeOffset CreatedAt);
|
|
```
|
|
|
|
Create `backend/Dtos/CreateTodoRequest.cs`:
|
|
|
|
```csharp
|
|
using System.ComponentModel.DataAnnotations;
|
|
|
|
namespace Backend.Dtos;
|
|
|
|
public record CreateTodoRequest([Required, StringLength(500, MinimumLength = 1)] string Title);
|
|
```
|
|
|
|
Create `backend/Dtos/UpdateTodoRequest.cs`:
|
|
|
|
```csharp
|
|
namespace Backend.Dtos;
|
|
|
|
public record UpdateTodoRequest(bool? Completed);
|
|
```
|
|
|
|
- [ ] **Step 2: Create `TodosController`**
|
|
|
|
Create `backend/Controllers/TodosController.cs`:
|
|
|
|
```csharp
|
|
using System.Security.Claims;
|
|
using Backend.Data;
|
|
using Backend.Dtos;
|
|
using Backend.Models;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Backend.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/todos")]
|
|
[Authorize]
|
|
public class TodosController(AppDbContext db) : ControllerBase
|
|
{
|
|
private Guid CurrentUserId()
|
|
{
|
|
var sub = User.FindFirstValue("sub")
|
|
?? throw new InvalidOperationException("Authenticated request missing 'sub' claim");
|
|
return Guid.Parse(sub);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<ActionResult<IEnumerable<TodoDto>>> List()
|
|
{
|
|
var userId = CurrentUserId();
|
|
var todos = await db.Todos
|
|
.Where(t => t.UserId == userId)
|
|
.OrderByDescending(t => t.CreatedAt)
|
|
.Select(t => new TodoDto(t.Id, t.Title, t.Completed, t.CreatedAt))
|
|
.ToListAsync();
|
|
return Ok(todos);
|
|
}
|
|
|
|
[HttpPost]
|
|
public async Task<ActionResult<TodoDto>> Create([FromBody] CreateTodoRequest body)
|
|
{
|
|
if (!ModelState.IsValid) return ValidationProblem(ModelState);
|
|
|
|
var userId = CurrentUserId();
|
|
var todo = new Todo
|
|
{
|
|
UserId = userId,
|
|
Title = body.Title,
|
|
Completed = false,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
db.Todos.Add(todo);
|
|
await db.SaveChangesAsync();
|
|
|
|
var dto = new TodoDto(todo.Id, todo.Title, todo.Completed, todo.CreatedAt);
|
|
return CreatedAtAction(nameof(List), new { id = todo.Id }, dto);
|
|
}
|
|
|
|
[HttpPatch("{id:long}")]
|
|
public async Task<ActionResult<TodoDto>> Update(long id, [FromBody] UpdateTodoRequest body)
|
|
{
|
|
var userId = CurrentUserId();
|
|
var todo = await db.Todos.FirstOrDefaultAsync(t => t.Id == id && t.UserId == userId);
|
|
if (todo is null) return NotFound();
|
|
|
|
if (body.Completed.HasValue) todo.Completed = body.Completed.Value;
|
|
await db.SaveChangesAsync();
|
|
|
|
return Ok(new TodoDto(todo.Id, todo.Title, todo.Completed, todo.CreatedAt));
|
|
}
|
|
|
|
[HttpDelete("{id:long}")]
|
|
public async Task<IActionResult> Delete(long id)
|
|
{
|
|
var userId = CurrentUserId();
|
|
var affected = await db.Todos
|
|
.Where(t => t.Id == id && t.UserId == userId)
|
|
.ExecuteDeleteAsync();
|
|
return affected == 0 ? NotFound() : NoContent();
|
|
}
|
|
}
|
|
```
|
|
|
|
Note: `CreatedAt` is stamped by the app (not the DB default) so the returned DTO matches what's stored without a re-read.
|
|
|
|
- [ ] **Step 3: Verify build**
|
|
|
|
```bash
|
|
cd backend
|
|
dotnet build
|
|
```
|
|
|
|
Expected: `Build succeeded. 0 Warning(s). 0 Error(s).`
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add backend/
|
|
git commit -m "Add TodosController with GET/POST/PATCH/DELETE endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: CORS for the Vite dev server
|
|
|
|
**Files:**
|
|
- Modify: `backend/Program.cs`
|
|
|
|
- [ ] **Step 1: Register and apply the CORS policy**
|
|
|
|
Open `backend/Program.cs`. Replace the line `// MARKER: CORS SERVICES` with:
|
|
|
|
```csharp
|
|
const string DevCorsPolicy = "DevCors";
|
|
builder.Services.AddCors(options =>
|
|
{
|
|
options.AddPolicy(DevCorsPolicy, policy => policy
|
|
.WithOrigins("http://localhost:5173")
|
|
.AllowAnyHeader()
|
|
.WithMethods("GET", "POST", "PATCH", "DELETE"));
|
|
});
|
|
```
|
|
|
|
Replace the line `// MARKER: CORS MIDDLEWARE` with:
|
|
|
|
```csharp
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.UseCors(DevCorsPolicy);
|
|
}
|
|
```
|
|
|
|
`UseCors` must come before `UseAuthentication` in the middleware chain, which the marker order in Task 4 guarantees.
|
|
|
|
- [ ] **Step 2: Verify startup**
|
|
|
|
```bash
|
|
cd backend
|
|
dotnet run &
|
|
sleep 4
|
|
kill %1 2>/dev/null
|
|
```
|
|
|
|
No exceptions. Full CORS behavior gets verified during end-to-end testing.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add backend/
|
|
git commit -m "Enable CORS for the Vite dev origin"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: End-to-end verify the backend with a real Supabase token
|
|
|
|
**Files:** none
|
|
|
|
This task uses only shell commands to prove the backend works. Do not commit anything here.
|
|
|
|
- [ ] **Step 1: Start the backend**
|
|
|
|
```bash
|
|
cd backend
|
|
dotnet run &
|
|
sleep 5
|
|
```
|
|
|
|
- [ ] **Step 2: Confirm unauthenticated requests are rejected**
|
|
|
|
```bash
|
|
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/api/todos
|
|
```
|
|
|
|
Expected: `401`.
|
|
|
|
- [ ] **Step 3: Create a Supabase user + get an access token via the Supabase auth REST API**
|
|
|
|
Use a throwaway email (e.g. `dev+$(date +%s)@example.com`). The `apikey` header is the **publishable** key.
|
|
|
|
```bash
|
|
SB_URL="https://jrbqfctqhjttxobtoqts.supabase.co"
|
|
SB_PUB="sb_publishable_QajbbpfedzxWEhCPBKTZqg_M-8qyCUy"
|
|
EMAIL="dev+$(date +%s)@example.com"
|
|
PASS="testpassword123"
|
|
|
|
# Sign up (returns a session if email confirmation is off)
|
|
SIGNUP=$(curl -s -X POST "$SB_URL/auth/v1/signup" \
|
|
-H "apikey: $SB_PUB" -H "Content-Type: application/json" \
|
|
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}")
|
|
echo "$SIGNUP" | head -c 200; echo
|
|
|
|
# Extract access_token (grep-based; no jq assumed)
|
|
TOKEN=$(echo "$SIGNUP" | sed -E 's/.*"access_token":"([^"]+)".*/\1/')
|
|
echo "TOKEN length: ${#TOKEN}"
|
|
```
|
|
|
|
If `TOKEN` length is under 100 chars, sign-up did NOT return a session — email confirmation is on. Fix: in the Supabase dashboard, Authentication → Providers → Email, toggle **Confirm email** off, then re-run this step with a new email.
|
|
|
|
- [ ] **Step 4: GET (empty list)**
|
|
|
|
```bash
|
|
curl -s -w "\nHTTP %{http_code}\n" -H "Authorization: Bearer $TOKEN" http://localhost:5000/api/todos
|
|
```
|
|
|
|
Expected: `[]` then `HTTP 200`.
|
|
|
|
- [ ] **Step 5: POST (create)**
|
|
|
|
```bash
|
|
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:5000/api/todos \
|
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
-d '{"title":"buy milk"}'
|
|
```
|
|
|
|
Expected: a JSON body with `id`, `title:"buy milk"`, `completed:false`, `createdAt`, then `HTTP 201`. Note the `id` for the next step (call it `TID`).
|
|
|
|
- [ ] **Step 6: PATCH (mark complete)**
|
|
|
|
```bash
|
|
TID=<id from previous step>
|
|
curl -s -w "\nHTTP %{http_code}\n" -X PATCH "http://localhost:5000/api/todos/$TID" \
|
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
-d '{"completed":true}'
|
|
```
|
|
|
|
Expected: JSON with `completed:true`, `HTTP 200`.
|
|
|
|
- [ ] **Step 7: DELETE**
|
|
|
|
```bash
|
|
curl -s -w "\nHTTP %{http_code}\n" -X DELETE "http://localhost:5000/api/todos/$TID" \
|
|
-H "Authorization: Bearer $TOKEN"
|
|
```
|
|
|
|
Expected: empty body, `HTTP 204`.
|
|
|
|
- [ ] **Step 8: Confirm cross-user isolation** (optional but recommended)
|
|
|
|
Sign up a second user (repeat Step 3 with a different email), grab their token, try `PATCH`/`DELETE` on `TID`. Expect `HTTP 404` for both — no leakage.
|
|
|
|
- [ ] **Step 9: Stop the backend**
|
|
|
|
```bash
|
|
kill %1 2>/dev/null
|
|
```
|
|
|
|
If any step fails, stop and debug before proceeding to the frontend.
|
|
|
|
---
|
|
|
|
## Task 9: Scaffold the Vite + React + TypeScript frontend
|
|
|
|
**Files:**
|
|
- Create: `frontend/package.json`
|
|
- Create: `frontend/vite.config.ts`
|
|
- Create: `frontend/tsconfig.json`
|
|
- Create: `frontend/index.html`
|
|
- Create: `frontend/src/main.tsx`
|
|
- Create: `frontend/src/App.tsx`
|
|
- Create: `frontend/src/index.css`
|
|
|
|
- [ ] **Step 1: Scaffold with `create-vite`**
|
|
|
|
From the repo root:
|
|
|
|
```bash
|
|
npm create vite@latest frontend -- --template react-ts
|
|
cd frontend
|
|
npm install
|
|
```
|
|
|
|
- [ ] **Step 2: Install runtime dependencies**
|
|
|
|
```bash
|
|
cd frontend
|
|
npm install @supabase/supabase-js
|
|
```
|
|
|
|
- [ ] **Step 3: Verify the scaffold builds and dev server runs**
|
|
|
|
```bash
|
|
cd frontend
|
|
npm run build # tsc + vite build
|
|
npm run dev &
|
|
sleep 3
|
|
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5173/
|
|
kill %1 2>/dev/null
|
|
```
|
|
|
|
Expected: build succeeds; `curl` returns `200`.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/
|
|
git commit -m "Scaffold Vite + React + TypeScript frontend"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Frontend env + Supabase client
|
|
|
|
**Files:**
|
|
- Create: `frontend/.env.example`
|
|
- Create: `frontend/.env.local` (gitignored)
|
|
- Create: `frontend/src/lib/supabase.ts`
|
|
|
|
- [ ] **Step 1: Create the env template**
|
|
|
|
Create `frontend/.env.example`:
|
|
|
|
```
|
|
VITE_SUPABASE_URL=https://YOURPROJECT.supabase.co
|
|
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_YOURKEY
|
|
VITE_API_URL=http://localhost:5000
|
|
```
|
|
|
|
- [ ] **Step 2: Create `frontend/.env.local` with real values**
|
|
|
|
```
|
|
VITE_SUPABASE_URL=https://jrbqfctqhjttxobtoqts.supabase.co
|
|
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_QajbbpfedzxWEhCPBKTZqg_M-8qyCUy
|
|
VITE_API_URL=http://localhost:5000
|
|
```
|
|
|
|
- [ ] **Step 3: Create the Supabase client module**
|
|
|
|
Create `frontend/src/lib/supabase.ts`:
|
|
|
|
```typescript
|
|
import { createClient } from '@supabase/supabase-js';
|
|
|
|
const url = import.meta.env.VITE_SUPABASE_URL;
|
|
const publishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
|
|
|
|
if (!url || !publishableKey) {
|
|
throw new Error(
|
|
'Missing VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY. Copy .env.example to .env.local and fill it in.'
|
|
);
|
|
}
|
|
|
|
export const supabase = createClient(url, publishableKey);
|
|
```
|
|
|
|
- [ ] **Step 4: Verify build still passes**
|
|
|
|
```bash
|
|
cd frontend
|
|
npm run build
|
|
```
|
|
|
|
Expected: success.
|
|
|
|
- [ ] **Step 5: Confirm `.env.local` is gitignored**
|
|
|
|
```bash
|
|
git status --short frontend/.env.local
|
|
```
|
|
|
|
Expected: no output (ignored).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add frontend/.env.example frontend/src/lib/supabase.ts
|
|
git commit -m "Add Supabase client and env template"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: `AuthProvider` (session state via `onAuthStateChange`)
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/auth/AuthProvider.tsx`
|
|
|
|
- [ ] **Step 1: Create the provider**
|
|
|
|
Create `frontend/src/auth/AuthProvider.tsx`:
|
|
|
|
```tsx
|
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
|
import type { Session } from '@supabase/supabase-js';
|
|
import { supabase } from '../lib/supabase';
|
|
|
|
type AuthContextValue = {
|
|
session: Session | null;
|
|
loading: boolean;
|
|
};
|
|
|
|
const AuthContext = createContext<AuthContextValue>({ session: null, loading: true });
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
const [session, setSession] = useState<Session | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const { data } = supabase.auth.onAuthStateChange((_event, s) => {
|
|
setSession(s);
|
|
setLoading(false);
|
|
});
|
|
return () => {
|
|
data.subscription.unsubscribe();
|
|
};
|
|
}, []);
|
|
|
|
return <AuthContext.Provider value={{ session, loading }}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
export function useAuth() {
|
|
return useContext(AuthContext);
|
|
}
|
|
```
|
|
|
|
Notes:
|
|
- `onAuthStateChange` fires an `INITIAL_SESSION` event immediately after subscribing, so a separate `getSession()` call is redundant.
|
|
- Supabase JS auto-refreshes the access token before expiry; the callback re-fires with the fresh session.
|
|
|
|
- [ ] **Step 2: Verify build**
|
|
|
|
```bash
|
|
cd frontend
|
|
npm run build
|
|
```
|
|
|
|
Expected: success (the file is unused so far, but TypeScript compiles it).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/auth/AuthProvider.tsx
|
|
git commit -m "Add AuthProvider tracking Supabase session"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: `SignIn` — sign-in / sign-up form
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/auth/SignIn.tsx`
|
|
|
|
- [ ] **Step 1: Create the component**
|
|
|
|
Create `frontend/src/auth/SignIn.tsx`:
|
|
|
|
```tsx
|
|
import { useState, type FormEvent } from 'react';
|
|
import { supabase } from '../lib/supabase';
|
|
|
|
type Mode = 'signIn' | 'signUp';
|
|
|
|
export function SignIn() {
|
|
const [mode, setMode] = useState<Mode>('signIn');
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
async function onSubmit(e: FormEvent) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
const { error } =
|
|
mode === 'signIn'
|
|
? await supabase.auth.signInWithPassword({ email, password })
|
|
: await supabase.auth.signUp({ email, password });
|
|
if (error) setError(error.message);
|
|
// On success, AuthProvider's onAuthStateChange updates session and the UI switches.
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ maxWidth: 320, margin: '4rem auto', fontFamily: 'sans-serif' }}>
|
|
<h1>{mode === 'signIn' ? 'Sign in' : 'Sign up'}</h1>
|
|
<form onSubmit={onSubmit}>
|
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
|
Email
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
style={{ width: '100%', padding: 6 }}
|
|
/>
|
|
</label>
|
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
|
Password
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
minLength={6}
|
|
style={{ width: '100%', padding: 6 }}
|
|
/>
|
|
</label>
|
|
<button type="submit" disabled={busy} style={{ width: '100%', padding: 8 }}>
|
|
{busy ? 'Working…' : mode === 'signIn' ? 'Sign in' : 'Sign up'}
|
|
</button>
|
|
</form>
|
|
{error && <p style={{ color: 'crimson' }}>{error}</p>}
|
|
<p>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode(mode === 'signIn' ? 'signUp' : 'signIn')}
|
|
style={{ background: 'none', border: 'none', color: '#06f', cursor: 'pointer' }}
|
|
>
|
|
{mode === 'signIn' ? "Don't have an account? Sign up" : 'Have an account? Sign in'}
|
|
</button>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify build**
|
|
|
|
```bash
|
|
cd frontend
|
|
npm run build
|
|
```
|
|
|
|
Expected: success.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/auth/SignIn.tsx
|
|
git commit -m "Add sign-in / sign-up form"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: API fetch wrapper (attaches Bearer token)
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/lib/api.ts`
|
|
|
|
- [ ] **Step 1: Create the API module**
|
|
|
|
Create `frontend/src/lib/api.ts`:
|
|
|
|
```typescript
|
|
import { supabase } from './supabase';
|
|
|
|
const API_URL = import.meta.env.VITE_API_URL;
|
|
if (!API_URL) throw new Error('Missing VITE_API_URL');
|
|
|
|
export type Todo = {
|
|
id: number;
|
|
title: string;
|
|
completed: boolean;
|
|
createdAt: string;
|
|
};
|
|
|
|
async function authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
|
const { data } = await supabase.auth.getSession();
|
|
const token = data.session?.access_token;
|
|
if (!token) throw new Error('Not signed in');
|
|
|
|
const headers = new Headers(init.headers);
|
|
headers.set('Authorization', `Bearer ${token}`);
|
|
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
|
|
|
return fetch(`${API_URL}${path}`, { ...init, headers });
|
|
}
|
|
|
|
async function assertOk(res: Response): Promise<Response> {
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
throw new Error(`API ${res.status}: ${text || res.statusText}`);
|
|
}
|
|
return res;
|
|
}
|
|
|
|
export const api = {
|
|
async list(): Promise<Todo[]> {
|
|
const res = await assertOk(await authedFetch('/api/todos'));
|
|
return res.json();
|
|
},
|
|
async create(title: string): Promise<Todo> {
|
|
const res = await assertOk(
|
|
await authedFetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) })
|
|
);
|
|
return res.json();
|
|
},
|
|
async setCompleted(id: number, completed: boolean): Promise<Todo> {
|
|
const res = await assertOk(
|
|
await authedFetch(`/api/todos/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ completed })
|
|
})
|
|
);
|
|
return res.json();
|
|
},
|
|
async remove(id: number): Promise<void> {
|
|
await assertOk(await authedFetch(`/api/todos/${id}`, { method: 'DELETE' }));
|
|
}
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Verify build**
|
|
|
|
```bash
|
|
cd frontend
|
|
npm run build
|
|
```
|
|
|
|
Expected: success.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/lib/api.ts
|
|
git commit -m "Add authenticated fetch wrapper for the todos API"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: `TodoList` component (list + add + toggle + delete)
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/todos/TodoList.tsx`
|
|
|
|
- [ ] **Step 1: Create the component**
|
|
|
|
Create `frontend/src/todos/TodoList.tsx`:
|
|
|
|
```tsx
|
|
import { useEffect, useState, type FormEvent } from 'react';
|
|
import { api, type Todo } from '../lib/api';
|
|
import { supabase } from '../lib/supabase';
|
|
|
|
export function TodoList({ userEmail }: { userEmail: string }) {
|
|
const [todos, setTodos] = useState<Todo[]>([]);
|
|
const [newTitle, setNewTitle] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
async function refresh() {
|
|
try {
|
|
setTodos(await api.list());
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
refresh().finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
async function onAdd(e: FormEvent) {
|
|
e.preventDefault();
|
|
const title = newTitle.trim();
|
|
if (!title) return;
|
|
setError(null);
|
|
try {
|
|
const created = await api.create(title);
|
|
setTodos((prev) => [created, ...prev]);
|
|
setNewTitle('');
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
}
|
|
|
|
async function onToggle(todo: Todo) {
|
|
setError(null);
|
|
try {
|
|
const updated = await api.setCompleted(todo.id, !todo.completed);
|
|
setTodos((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
}
|
|
|
|
async function onDelete(todo: Todo) {
|
|
setError(null);
|
|
try {
|
|
await api.remove(todo.id);
|
|
setTodos((prev) => prev.filter((t) => t.id !== todo.id));
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
}
|
|
|
|
async function onSignOut() {
|
|
await supabase.auth.signOut();
|
|
}
|
|
|
|
return (
|
|
<div style={{ maxWidth: 520, margin: '2rem auto', fontFamily: 'sans-serif' }}>
|
|
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
|
<h1>Todos</h1>
|
|
<span>
|
|
{userEmail}{' '}
|
|
<button type="button" onClick={onSignOut}>Sign out</button>
|
|
</span>
|
|
</header>
|
|
|
|
<form onSubmit={onAdd} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
|
<input
|
|
value={newTitle}
|
|
onChange={(e) => setNewTitle(e.target.value)}
|
|
placeholder="What needs doing?"
|
|
maxLength={500}
|
|
style={{ flex: 1, padding: 6 }}
|
|
/>
|
|
<button type="submit">Add</button>
|
|
</form>
|
|
|
|
{error && <p style={{ color: 'crimson' }}>{error}</p>}
|
|
{loading ? (
|
|
<p>Loading…</p>
|
|
) : todos.length === 0 ? (
|
|
<p>No todos yet.</p>
|
|
) : (
|
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
|
{todos.map((t) => (
|
|
<li
|
|
key={t.id}
|
|
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}
|
|
>
|
|
<input type="checkbox" checked={t.completed} onChange={() => onToggle(t)} />
|
|
<span style={{ flex: 1, textDecoration: t.completed ? 'line-through' : 'none' }}>
|
|
{t.title}
|
|
</span>
|
|
<button type="button" onClick={() => onDelete(t)}>Delete</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify build**
|
|
|
|
```bash
|
|
cd frontend
|
|
npm run build
|
|
```
|
|
|
|
Expected: success.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/todos/TodoList.tsx
|
|
git commit -m "Add TodoList component"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: Wire it all together in `App.tsx` and `main.tsx`
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/main.tsx`
|
|
- Modify: `frontend/src/App.tsx`
|
|
|
|
- [ ] **Step 1: Wrap the app in `AuthProvider`**
|
|
|
|
Replace `frontend/src/main.tsx` with:
|
|
|
|
```tsx
|
|
import { StrictMode } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { AuthProvider } from './auth/AuthProvider';
|
|
import { App } from './App';
|
|
import './index.css';
|
|
|
|
createRoot(document.getElementById('root')!).render(
|
|
<StrictMode>
|
|
<AuthProvider>
|
|
<App />
|
|
</AuthProvider>
|
|
</StrictMode>
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 2: Route between `SignIn` and `TodoList` based on session**
|
|
|
|
Replace `frontend/src/App.tsx` with:
|
|
|
|
```tsx
|
|
import { useAuth } from './auth/AuthProvider';
|
|
import { SignIn } from './auth/SignIn';
|
|
import { TodoList } from './todos/TodoList';
|
|
|
|
export function App() {
|
|
const { session, loading } = useAuth();
|
|
|
|
if (loading) return <p style={{ textAlign: 'center', marginTop: '4rem' }}>Loading…</p>;
|
|
if (!session) return <SignIn />;
|
|
return <TodoList userEmail={session.user.email ?? '(no email)'} />;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify build**
|
|
|
|
```bash
|
|
cd frontend
|
|
npm run build
|
|
```
|
|
|
|
Expected: success, no TS errors.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/main.tsx frontend/src/App.tsx
|
|
git commit -m "Route between sign-in and todo list based on session"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 16: End-to-end verification in the browser
|
|
|
|
**Files:** none
|
|
|
|
This is a manual smoke test. Do not commit anything here.
|
|
|
|
- [ ] **Step 1: Start both processes**
|
|
|
|
Two terminals from the repo root:
|
|
|
|
```bash
|
|
# Terminal 1
|
|
cd backend && dotnet run
|
|
|
|
# Terminal 2
|
|
cd frontend && npm run dev
|
|
```
|
|
|
|
Expected: backend on `http://localhost:5000`, Vite on `http://localhost:5173`.
|
|
|
|
- [ ] **Step 2: Sign up**
|
|
|
|
Open `http://localhost:5173`. Click "Don't have an account? Sign up". Enter an email like `dev+$(date +%s)@example.com` and a password ≥ 6 chars. Submit.
|
|
|
|
Expected: the view switches to the todo list (empty).
|
|
|
|
If instead you see an error like "email not confirmed" or the view doesn't switch, email confirmation is on for this Supabase project. Turn it off (Dashboard → Authentication → Providers → Email → "Confirm email"), sign up with a new email, and continue.
|
|
|
|
- [ ] **Step 3: Add a todo**
|
|
|
|
Type "buy milk", click Add.
|
|
|
|
Expected: the item appears at the top of the list, `[ ] buy milk [Delete]`. The Network tab shows `POST /api/todos` → 201.
|
|
|
|
- [ ] **Step 4: Toggle complete**
|
|
|
|
Click the checkbox next to "buy milk".
|
|
|
|
Expected: the text gets strikethrough, checkbox stays ticked. Network shows `PATCH /api/todos/{id}` → 200.
|
|
|
|
- [ ] **Step 5: Delete**
|
|
|
|
Click Delete.
|
|
|
|
Expected: the item disappears. Network shows `DELETE /api/todos/{id}` → 204.
|
|
|
|
- [ ] **Step 6: Sign out + sign back in**
|
|
|
|
Click Sign out. Sign back in with the same email + password. Add another todo. Reload the page.
|
|
|
|
Expected: the todo persists across reloads; the session is restored automatically from `localStorage`.
|
|
|
|
- [ ] **Step 7: Cross-user isolation (optional)**
|
|
|
|
Sign out. Sign up as a second user with a different email. Confirm the todo list is empty (the first user's todos are not visible).
|
|
|
|
- [ ] **Step 8: Stop both processes**
|
|
|
|
`Ctrl+C` in each terminal.
|
|
|
|
If any step fails, debug in the browser DevTools (Console + Network) and in the backend log before continuing.
|
|
|
|
---
|
|
|
|
## Task 17: Finalize the README
|
|
|
|
**Files:**
|
|
- Modify: `README.md`
|
|
|
|
- [ ] **Step 1: Rewrite `README.md` with real setup instructions**
|
|
|
|
Replace `README.md`:
|
|
|
|
```markdown
|
|
# supabase_test
|
|
|
|
Minimal per-user to-do list.
|
|
|
|
- **Frontend:** React 18 + TypeScript, built with Vite. Uses `@supabase/supabase-js` only for authentication.
|
|
- **Backend:** ASP.NET Core 9 Web API. Validates Supabase-issued JWTs against the project's JWKS (via OpenID discovery), talks to Postgres directly with EF Core + Npgsql.
|
|
- **Auth + DB:** Supabase.
|
|
|
|
See [the design doc](docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md) for the architecture and rationale.
|
|
|
|
## Prerequisites
|
|
|
|
- .NET SDK 9.0
|
|
- Node.js 20+
|
|
- A Supabase project (URL, publishable key, and Postgres password to hand)
|
|
|
|
## One-time setup
|
|
|
|
1. Apply the database migration to your Supabase project. Either:
|
|
- Open the Supabase dashboard → SQL Editor → paste `migrations/001_create_todos.sql` → Run, or
|
|
- `psql "host=db.<PROJECT>.supabase.co port=5432 dbname=postgres user=postgres sslmode=require" -f migrations/001_create_todos.sql`
|
|
2. In the Supabase dashboard, **Authentication → Providers → Email → toggle "Confirm email" off** for local dev, so sign-up returns a session immediately.
|
|
3. Backend config:
|
|
```bash
|
|
cp backend/appsettings.Development.example.json backend/appsettings.Development.json
|
|
# Fill in the DB password and (if different) the project URL.
|
|
```
|
|
4. Frontend config:
|
|
```bash
|
|
cp frontend/.env.example frontend/.env.local
|
|
# Fill in VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, VITE_API_URL.
|
|
```
|
|
5. Install frontend deps:
|
|
```bash
|
|
cd frontend && npm install
|
|
```
|
|
|
|
## Run it
|
|
|
|
Two terminals:
|
|
|
|
```bash
|
|
# Terminal 1
|
|
cd backend && dotnet run # http://localhost:5000
|
|
|
|
# Terminal 2
|
|
cd frontend && npm run dev # http://localhost:5173
|
|
```
|
|
|
|
Open `http://localhost:5173`, sign up, add todos.
|
|
|
|
## Layout
|
|
|
|
```
|
|
backend/ ASP.NET Core Web API
|
|
frontend/ Vite + React + TypeScript SPA
|
|
migrations/ Plain SQL files applied to Supabase Postgres
|
|
docs/ Design and implementation-plan docs
|
|
```
|
|
```
|
|
|
|
- [ ] **Step 2: Commit and push**
|
|
|
|
```bash
|
|
git add README.md
|
|
git commit -m "Update README with real setup instructions"
|
|
git push
|
|
```
|
|
|
|
---
|
|
|
|
## Done criteria
|
|
|
|
- All 17 tasks completed with commits.
|
|
- The end-to-end verification (Task 16) passes every step.
|
|
- `backend/appsettings.Development.json` and `frontend/.env.local` are not tracked by git (`git status` clean, `git ls-files` does not list them).
|
|
- A fresh clone + the "One-time setup" steps in the README + `dotnet run` + `npm run dev` yields a working app.
|