From 81e574a8d1e1dbecae4cbd349c3df5e3a593c5b0 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 21 Oct 2025 11:22:22 +0100 Subject: [PATCH 1/7] feat(auth): add migration for auth fields and update test data scripts - Add migration to create email and line_manager fields on profiles table - Add unique constraint on user_id for ON CONFLICT support in trigger - Create auto-profile trigger for new auth.users signups - Add RLS policies for authenticated user access - Update test data source with line_manager_name and line_manager_email - Modify data generation scripts to use simplified line manager approach - Skip old organizations and line_managers table population (backward compatible) - Update profiles via UPDATE instead of INSERT to work with trigger --- scripts/generate-test-data.sh | 52 ++++---- scripts/prod-seed-test-data.sh | 5 +- supabase/data/test_data_source.json | 40 ++++-- supabase/generated/test_fake_data.sql | 123 +++++++++++++----- ...1021100148_add_auth_fields_to_profiles.sql | 115 ++++++++++++++++ 5 files changed, 266 insertions(+), 69 deletions(-) create mode 100644 supabase/migrations/20251021100148_add_auth_fields_to_profiles.sql diff --git a/scripts/generate-test-data.sh b/scripts/generate-test-data.sh index aa88c76..092a576 100755 --- a/scripts/generate-test-data.sh +++ b/scripts/generate-test-data.sh @@ -86,14 +86,17 @@ cat > supabase/generated/test_fake_data.sql << 'EOF' -- DO NOT EDIT MANUALLY - run scripts/generate-test-data.sh to regenerate -- =========================================== --- ORGANIZATIONS +-- ORGANIZATIONS (skipped - using simplified line manager fields instead) -- =========================================== +-- We now use line_manager_name and line_manager_email fields directly on profiles +-- The old organizations and line_managers tables are kept for backward compatibility +-- but not populated in test data EOF -# Add organizations -echo "$TEST_DATA" | jq -r '.organizations[] | -"INSERT INTO organizations (id, name) VALUES (\u0027" + .id + "\u0027::uuid, \u0027" + .name + "\u0027);"' >> supabase/generated/test_fake_data.sql +# Skip organizations - we're using simplified approach +# echo "$TEST_DATA" | jq -r '.organizations[] | +# "INSERT INTO organizations (id, name) VALUES (\u0027" + .id + "\u0027::uuid, \u0027" + .name + "\u0027);"' >> supabase/generated/test_fake_data.sql echo "" >> supabase/generated/test_fake_data.sql echo "-- ===========================================" >> supabase/generated/test_fake_data.sql @@ -135,36 +138,37 @@ echo "$TEST_DATA" | jq -r '.users[] | echo "" >> supabase/generated/test_fake_data.sql echo "-- ===========================================" >> supabase/generated/test_fake_data.sql -echo "-- PROFILES (step 1: without line_manager FK)" >> supabase/generated/test_fake_data.sql +echo "-- PROFILES (auto-created by trigger, update with additional fields)" >> supabase/generated/test_fake_data.sql echo "-- ===========================================" >> supabase/generated/test_fake_data.sql echo "" >> supabase/generated/test_fake_data.sql -# Add profiles without line_manager FK first -echo "INSERT INTO profiles (id, user_id, name, pronouns, job_title, is_line_manager) VALUES" >> supabase/generated/test_fake_data.sql - -echo "$TEST_DATA" | jq -r '.users | length as $len | to_entries | map( - " (\u0027" + .value.id + "\u0027::uuid, \u0027" + .value.id + "\u0027::uuid, \u0027" + .value.name + "\u0027, ARRAY[" + (.value.pronouns | map("\u0027" + . + "\u0027") | join(", ")) + "], \u0027" + .value.job_title + "\u0027, " + (.value.is_line_manager | tostring) + ")" + (if (.key == ($len - 1)) then ";" else "," end) -) | join("\n")' >> supabase/generated/test_fake_data.sql +# Update profiles created by trigger with additional fields +echo "$TEST_DATA" | jq -r '.users[] | +"UPDATE profiles SET + name = \u0027" + .name + "\u0027, + pronouns = ARRAY[" + (.pronouns | map("\u0027" + . + "\u0027") | join(", ")) + "], + job_title = \u0027" + .job_title + "\u0027, + is_line_manager = " + (.is_line_manager | tostring) + ", + line_manager_name = " + (if .line_manager_name then "\u0027" + .line_manager_name + "\u0027" else "NULL" end) + ", + line_manager_email = " + (if .line_manager_email then "\u0027" + .line_manager_email + "\u0027" else "NULL" end) + " +WHERE user_id = \u0027" + .id + "\u0027::uuid;"' >> supabase/generated/test_fake_data.sql echo "" >> supabase/generated/test_fake_data.sql echo "-- ===========================================" >> supabase/generated/test_fake_data.sql -echo "-- LINE MANAGERS" >> supabase/generated/test_fake_data.sql +echo "-- LINE MANAGERS (skipped - using simplified approach)" >> supabase/generated/test_fake_data.sql echo "-- ===========================================" >> supabase/generated/test_fake_data.sql +echo "-- We now store line manager info directly on profiles via:" >> supabase/generated/test_fake_data.sql +echo "-- - line_manager_name (TEXT)" >> supabase/generated/test_fake_data.sql +echo "-- - line_manager_email (TEXT)" >> supabase/generated/test_fake_data.sql echo "" >> supabase/generated/test_fake_data.sql -# Add line managers -echo "$TEST_DATA" | jq -r '.line_managers[] | -"INSERT INTO line_managers (id, line_manager_id, organization_id, email) VALUES (\u0027" + .id + "\u0027::uuid, \u0027" + .line_manager_id + "\u0027::uuid, \u0027" + .organization_id + "\u0027::uuid, \u0027" + .email + "\u0027);"' >> supabase/generated/test_fake_data.sql - -echo "" >> supabase/generated/test_fake_data.sql -echo "-- ===========================================" >> supabase/generated/test_fake_data.sql -echo "-- PROFILES (step 2: update line_manager FK)" >> supabase/generated/test_fake_data.sql -echo "-- ===========================================" >> supabase/generated/test_fake_data.sql -echo "" >> supabase/generated/test_fake_data.sql +# Skip line_managers table - we're using simplified approach +# echo "$TEST_DATA" | jq -r '.line_managers[] | +# "INSERT INTO line_managers (id, line_manager_id, organization_id, email) VALUES (\u0027" + .id + "\u0027::uuid, \u0027" + .line_manager_id + "\u0027::uuid, \u0027" + .organization_id + "\u0027::uuid, \u0027" + .email + "\u0027);"' >> supabase/generated/test_fake_data.sql -# Update profiles to set line_manager FK -echo "$TEST_DATA" | jq -r '.users[] | select(.line_manager != null) | -"UPDATE profiles SET line_manager = \u0027" + .line_manager + "\u0027::uuid WHERE id = \u0027" + .id + "\u0027::uuid;"' >> supabase/generated/test_fake_data.sql +# Skip old line_manager FK update +# echo "$TEST_DATA" | jq -r '.users[] | select(.line_manager != null) | +# "UPDATE profiles SET line_manager = \u0027" + .line_manager + "\u0027::uuid WHERE id = \u0027" + .id + "\u0027::uuid;"' >> supabase/generated/test_fake_data.sql echo "" >> supabase/generated/test_fake_data.sql echo "-- ===========================================" >> supabase/generated/test_fake_data.sql diff --git a/scripts/prod-seed-test-data.sh b/scripts/prod-seed-test-data.sh index a9a691a..28700e0 100755 --- a/scripts/prod-seed-test-data.sh +++ b/scripts/prod-seed-test-data.sh @@ -112,7 +112,10 @@ PROFILES_JSON=$(echo "$TEST_DATA" | jq '[.users[] | { name: .name, pronouns: .pronouns, job_title: .job_title, - is_line_manager: .is_line_manager + is_line_manager: .is_line_manager, + email: .email, + line_manager_name: .line_manager_name, + line_manager_email: .line_manager_email }]') PROFILE_RESPONSE=$(curl -s -X POST "$API_URL/profiles" \ diff --git a/supabase/data/test_data_source.json b/supabase/data/test_data_source.json index 72cf583..6fa0b8d 100644 --- a/supabase/data/test_data_source.json +++ b/supabase/data/test_data_source.json @@ -65,7 +65,9 @@ ], "job_title": "Software Developer", "is_line_manager": false, - "line_manager": "440e8400-e29b-41d4-a716-446655440001" + "line_manager": "440e8400-e29b-41d4-a716-446655440001", + "line_manager_name": "Sarah Wilson", + "line_manager_email": "sarah.wilson@techcorp.com" }, { "id": "550e8400-e29b-41d4-a716-446655440002", @@ -78,7 +80,9 @@ ], "job_title": "UX Designer", "is_line_manager": false, - "line_manager": "440e8400-e29b-41d4-a716-446655440002" + "line_manager": "440e8400-e29b-41d4-a716-446655440002", + "line_manager_name": "Mike Johnson", + "line_manager_email": "mike.johnson@creative.com" }, { "id": "550e8400-e29b-41d4-a716-446655440003", @@ -91,7 +95,9 @@ ], "job_title": "Data Analyst", "is_line_manager": false, - "line_manager": "440e8400-e29b-41d4-a716-446655440003" + "line_manager": "440e8400-e29b-41d4-a716-446655440003", + "line_manager_name": "Lisa Brown", + "line_manager_email": "lisa.brown@dataflow.com" }, { "id": "550e8400-e29b-41d4-a716-446655440004", @@ -104,7 +110,9 @@ ], "job_title": "Product Manager", "is_line_manager": false, - "line_manager": "440e8400-e29b-41d4-a716-446655440004" + "line_manager": "440e8400-e29b-41d4-a716-446655440004", + "line_manager_name": "David Kim", + "line_manager_email": "david.kim@innovatecorp.com" }, { "id": "550e8400-e29b-41d4-a716-446655440005", @@ -117,7 +125,9 @@ ], "job_title": "Marketing Specialist", "is_line_manager": false, - "line_manager": "440e8400-e29b-41d4-a716-446655440005" + "line_manager": "440e8400-e29b-41d4-a716-446655440005", + "line_manager_name": "Emma Rodriguez", + "line_manager_email": "emma.rodriguez@brandworks.com" }, { "id": "550e8400-e29b-41d4-a716-446655440010", @@ -130,7 +140,9 @@ ], "job_title": "Engineering Manager", "is_line_manager": true, - "line_manager": null + "line_manager": null, + "line_manager_name": null, + "line_manager_email": null }, { "id": "550e8400-e29b-41d4-a716-446655440011", @@ -143,7 +155,9 @@ ], "job_title": "Design Director", "is_line_manager": true, - "line_manager": null + "line_manager": null, + "line_manager_name": null, + "line_manager_email": null }, { "id": "550e8400-e29b-41d4-a716-446655440012", @@ -156,7 +170,9 @@ ], "job_title": "Analytics Lead", "is_line_manager": true, - "line_manager": null + "line_manager": null, + "line_manager_name": null, + "line_manager_email": null }, { "id": "550e8400-e29b-41d4-a716-446655440013", @@ -169,7 +185,9 @@ ], "job_title": "Product Director", "is_line_manager": true, - "line_manager": null + "line_manager": null, + "line_manager_name": null, + "line_manager_email": null }, { "id": "550e8400-e29b-41d4-a716-446655440014", @@ -182,7 +200,9 @@ ], "job_title": "Marketing Director", "is_line_manager": true, - "line_manager": null + "line_manager": null, + "line_manager_name": null, + "line_manager_email": null } ], "responses": [ diff --git a/supabase/generated/test_fake_data.sql b/supabase/generated/test_fake_data.sql index 24df2ae..3c24c1a 100644 --- a/supabase/generated/test_fake_data.sql +++ b/supabase/generated/test_fake_data.sql @@ -3,14 +3,12 @@ -- DO NOT EDIT MANUALLY - run scripts/generate-test-data.sh to regenerate -- =========================================== --- ORGANIZATIONS +-- ORGANIZATIONS (skipped - using simplified line manager fields instead) -- =========================================== +-- We now use line_manager_name and line_manager_email fields directly on profiles +-- The old organizations and line_managers tables are kept for backward compatibility +-- but not populated in test data -INSERT INTO organizations (id, name) VALUES ('990e8400-e29b-41d4-a716-446655440001'::uuid, 'TechCorp Ltd'); -INSERT INTO organizations (id, name) VALUES ('990e8400-e29b-41d4-a716-446655440002'::uuid, 'Creative Agency'); -INSERT INTO organizations (id, name) VALUES ('990e8400-e29b-41d4-a716-446655440003'::uuid, 'DataFlow Inc'); -INSERT INTO organizations (id, name) VALUES ('990e8400-e29b-41d4-a716-446655440004'::uuid, 'InnovateCorp'); -INSERT INTO organizations (id, name) VALUES ('990e8400-e29b-41d4-a716-446655440005'::uuid, 'BrandWorks'); -- =========================================== -- AUTH USERS (for local testing only) @@ -308,40 +306,97 @@ INSERT INTO auth.users ( ); -- =========================================== --- PROFILES (step 1: without line_manager FK) +-- PROFILES (auto-created by trigger, update with additional fields) -- =========================================== -INSERT INTO profiles (id, user_id, name, pronouns, job_title, is_line_manager) VALUES - ('550e8400-e29b-41d4-a716-446655440001'::uuid, '550e8400-e29b-41d4-a716-446655440001'::uuid, 'Alex Chen', ARRAY['they', 'them', 'theirs'], 'Software Developer', false), - ('550e8400-e29b-41d4-a716-446655440002'::uuid, '550e8400-e29b-41d4-a716-446655440002'::uuid, 'Jordan Taylor', ARRAY['she', 'her', 'hers'], 'UX Designer', false), - ('550e8400-e29b-41d4-a716-446655440003'::uuid, '550e8400-e29b-41d4-a716-446655440003'::uuid, 'Sam Mitchell', ARRAY['he', 'him', 'his'], 'Data Analyst', false), - ('550e8400-e29b-41d4-a716-446655440004'::uuid, '550e8400-e29b-41d4-a716-446655440004'::uuid, 'Riley Johnson', ARRAY['she', 'her', 'hers'], 'Product Manager', false), - ('550e8400-e29b-41d4-a716-446655440005'::uuid, '550e8400-e29b-41d4-a716-446655440005'::uuid, 'Casey Williams', ARRAY['he', 'him', 'his'], 'Marketing Specialist', false), - ('550e8400-e29b-41d4-a716-446655440010'::uuid, '550e8400-e29b-41d4-a716-446655440010'::uuid, 'Sarah Wilson', ARRAY['she', 'her', 'hers'], 'Engineering Manager', true), - ('550e8400-e29b-41d4-a716-446655440011'::uuid, '550e8400-e29b-41d4-a716-446655440011'::uuid, 'Mike Johnson', ARRAY['he', 'him', 'his'], 'Design Director', true), - ('550e8400-e29b-41d4-a716-446655440012'::uuid, '550e8400-e29b-41d4-a716-446655440012'::uuid, 'Lisa Brown', ARRAY['she', 'her', 'hers'], 'Analytics Lead', true), - ('550e8400-e29b-41d4-a716-446655440013'::uuid, '550e8400-e29b-41d4-a716-446655440013'::uuid, 'David Kim', ARRAY['he', 'him', 'his'], 'Product Director', true), - ('550e8400-e29b-41d4-a716-446655440014'::uuid, '550e8400-e29b-41d4-a716-446655440014'::uuid, 'Emma Rodriguez', ARRAY['she', 'her', 'hers'], 'Marketing Director', true); +UPDATE profiles SET + name = 'Alex Chen', + pronouns = ARRAY['they', 'them', 'theirs'], + job_title = 'Software Developer', + is_line_manager = false, + line_manager_name = 'Sarah Wilson', + line_manager_email = 'sarah.wilson@techcorp.com' +WHERE user_id = '550e8400-e29b-41d4-a716-446655440001'::uuid; +UPDATE profiles SET + name = 'Jordan Taylor', + pronouns = ARRAY['she', 'her', 'hers'], + job_title = 'UX Designer', + is_line_manager = false, + line_manager_name = 'Mike Johnson', + line_manager_email = 'mike.johnson@creative.com' +WHERE user_id = '550e8400-e29b-41d4-a716-446655440002'::uuid; +UPDATE profiles SET + name = 'Sam Mitchell', + pronouns = ARRAY['he', 'him', 'his'], + job_title = 'Data Analyst', + is_line_manager = false, + line_manager_name = 'Lisa Brown', + line_manager_email = 'lisa.brown@dataflow.com' +WHERE user_id = '550e8400-e29b-41d4-a716-446655440003'::uuid; +UPDATE profiles SET + name = 'Riley Johnson', + pronouns = ARRAY['she', 'her', 'hers'], + job_title = 'Product Manager', + is_line_manager = false, + line_manager_name = 'David Kim', + line_manager_email = 'david.kim@innovatecorp.com' +WHERE user_id = '550e8400-e29b-41d4-a716-446655440004'::uuid; +UPDATE profiles SET + name = 'Casey Williams', + pronouns = ARRAY['he', 'him', 'his'], + job_title = 'Marketing Specialist', + is_line_manager = false, + line_manager_name = 'Emma Rodriguez', + line_manager_email = 'emma.rodriguez@brandworks.com' +WHERE user_id = '550e8400-e29b-41d4-a716-446655440005'::uuid; +UPDATE profiles SET + name = 'Sarah Wilson', + pronouns = ARRAY['she', 'her', 'hers'], + job_title = 'Engineering Manager', + is_line_manager = true, + line_manager_name = NULL, + line_manager_email = NULL +WHERE user_id = '550e8400-e29b-41d4-a716-446655440010'::uuid; +UPDATE profiles SET + name = 'Mike Johnson', + pronouns = ARRAY['he', 'him', 'his'], + job_title = 'Design Director', + is_line_manager = true, + line_manager_name = NULL, + line_manager_email = NULL +WHERE user_id = '550e8400-e29b-41d4-a716-446655440011'::uuid; +UPDATE profiles SET + name = 'Lisa Brown', + pronouns = ARRAY['she', 'her', 'hers'], + job_title = 'Analytics Lead', + is_line_manager = true, + line_manager_name = NULL, + line_manager_email = NULL +WHERE user_id = '550e8400-e29b-41d4-a716-446655440012'::uuid; +UPDATE profiles SET + name = 'David Kim', + pronouns = ARRAY['he', 'him', 'his'], + job_title = 'Product Director', + is_line_manager = true, + line_manager_name = NULL, + line_manager_email = NULL +WHERE user_id = '550e8400-e29b-41d4-a716-446655440013'::uuid; +UPDATE profiles SET + name = 'Emma Rodriguez', + pronouns = ARRAY['she', 'her', 'hers'], + job_title = 'Marketing Director', + is_line_manager = true, + line_manager_name = NULL, + line_manager_email = NULL +WHERE user_id = '550e8400-e29b-41d4-a716-446655440014'::uuid; -- =========================================== --- LINE MANAGERS +-- LINE MANAGERS (skipped - using simplified approach) -- =========================================== +-- We now store line manager info directly on profiles via: +-- - line_manager_name (TEXT) +-- - line_manager_email (TEXT) -INSERT INTO line_managers (id, line_manager_id, organization_id, email) VALUES ('440e8400-e29b-41d4-a716-446655440001'::uuid, '550e8400-e29b-41d4-a716-446655440010'::uuid, '990e8400-e29b-41d4-a716-446655440001'::uuid, 'sarah.wilson@techcorp.com'); -INSERT INTO line_managers (id, line_manager_id, organization_id, email) VALUES ('440e8400-e29b-41d4-a716-446655440002'::uuid, '550e8400-e29b-41d4-a716-446655440011'::uuid, '990e8400-e29b-41d4-a716-446655440002'::uuid, 'mike.johnson@creative.com'); -INSERT INTO line_managers (id, line_manager_id, organization_id, email) VALUES ('440e8400-e29b-41d4-a716-446655440003'::uuid, '550e8400-e29b-41d4-a716-446655440012'::uuid, '990e8400-e29b-41d4-a716-446655440003'::uuid, 'lisa.brown@dataflow.com'); -INSERT INTO line_managers (id, line_manager_id, organization_id, email) VALUES ('440e8400-e29b-41d4-a716-446655440004'::uuid, '550e8400-e29b-41d4-a716-446655440013'::uuid, '990e8400-e29b-41d4-a716-446655440004'::uuid, 'david.kim@innovatecorp.com'); -INSERT INTO line_managers (id, line_manager_id, organization_id, email) VALUES ('440e8400-e29b-41d4-a716-446655440005'::uuid, '550e8400-e29b-41d4-a716-446655440014'::uuid, '990e8400-e29b-41d4-a716-446655440005'::uuid, 'emma.rodriguez@brandworks.com'); - --- =========================================== --- PROFILES (step 2: update line_manager FK) --- =========================================== - -UPDATE profiles SET line_manager = '440e8400-e29b-41d4-a716-446655440001'::uuid WHERE id = '550e8400-e29b-41d4-a716-446655440001'::uuid; -UPDATE profiles SET line_manager = '440e8400-e29b-41d4-a716-446655440002'::uuid WHERE id = '550e8400-e29b-41d4-a716-446655440002'::uuid; -UPDATE profiles SET line_manager = '440e8400-e29b-41d4-a716-446655440003'::uuid WHERE id = '550e8400-e29b-41d4-a716-446655440003'::uuid; -UPDATE profiles SET line_manager = '440e8400-e29b-41d4-a716-446655440004'::uuid WHERE id = '550e8400-e29b-41d4-a716-446655440004'::uuid; -UPDATE profiles SET line_manager = '440e8400-e29b-41d4-a716-446655440005'::uuid WHERE id = '550e8400-e29b-41d4-a716-446655440005'::uuid; -- =========================================== -- RESPONSES diff --git a/supabase/migrations/20251021100148_add_auth_fields_to_profiles.sql b/supabase/migrations/20251021100148_add_auth_fields_to_profiles.sql new file mode 100644 index 0000000..afe5e83 --- /dev/null +++ b/supabase/migrations/20251021100148_add_auth_fields_to_profiles.sql @@ -0,0 +1,115 @@ +-- Add email and line manager fields to profiles +ALTER TABLE profiles + ADD COLUMN IF NOT EXISTS email TEXT UNIQUE, + ADD COLUMN IF NOT EXISTS line_manager_name TEXT, + ADD COLUMN IF NOT EXISTS line_manager_email TEXT; + +-- Add unique constraint on user_id (required for ON CONFLICT in trigger) +ALTER TABLE profiles + ADD CONSTRAINT profiles_user_id_unique UNIQUE (user_id); + +-- Create index for faster lookups (may already exist) +CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id); + +-- Auto-create profile when user signs up +CREATE OR REPLACE FUNCTION handle_new_user() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.profiles (user_id, email, name, inserted_at, updated_at) + VALUES ( + NEW.id, + NEW.email, + COALESCE(NEW.raw_user_meta_data->>'name', ''), + NOW(), + NOW() + ) + ON CONFLICT (user_id) DO NOTHING; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW + EXECUTE FUNCTION handle_new_user(); + +-- Sync email when auth.users email changes +CREATE OR REPLACE FUNCTION sync_profile_email() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE profiles + SET email = NEW.email, updated_at = NOW() + WHERE user_id = NEW.id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS on_auth_user_email_updated ON auth.users; +CREATE TRIGGER on_auth_user_email_updated + AFTER UPDATE OF email ON auth.users + FOR EACH ROW + EXECUTE FUNCTION sync_profile_email(); + +-- Enable RLS on all tables if not already enabled +ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE responses ENABLE ROW LEVEL SECURITY; +ALTER TABLE actions ENABLE ROW LEVEL SECURITY; +ALTER TABLE sharing_events ENABLE ROW LEVEL SECURITY; + +-- Drop existing policies if they exist (to avoid conflicts) +DROP POLICY IF EXISTS "Users can view own profile" ON profiles; +DROP POLICY IF EXISTS "Users can update own profile" ON profiles; +DROP POLICY IF EXISTS "Users can insert own profile" ON profiles; +DROP POLICY IF EXISTS "Users can manage own responses" ON responses; +DROP POLICY IF EXISTS "Users can manage own actions" ON actions; +DROP POLICY IF EXISTS "Users can manage own sharing events" ON sharing_events; +DROP POLICY IF EXISTS "Service role full access profiles" ON profiles; +DROP POLICY IF EXISTS "Service role full access responses" ON responses; +DROP POLICY IF EXISTS "Service role full access actions" ON actions; +DROP POLICY IF EXISTS "Service role full access sharing_events" ON sharing_events; + +-- Profiles: Users can read/update their own +CREATE POLICY "Users can view own profile" + ON profiles FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can update own profile" + ON profiles FOR UPDATE + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own profile" + ON profiles FOR INSERT + WITH CHECK (auth.uid() = user_id); + +-- Responses: Users can CRUD their own +CREATE POLICY "Users can manage own responses" + ON responses FOR ALL + USING (auth.uid() = user_id); + +-- Actions: Users can CRUD their own +CREATE POLICY "Users can manage own actions" + ON actions FOR ALL + USING (auth.uid() = user_id); + +-- Sharing events: Users can manage their own +CREATE POLICY "Users can manage own sharing events" + ON sharing_events FOR ALL + USING (auth.uid() = user_id); + +-- Service role has full access (for migrations/admin) +CREATE POLICY "Service role full access profiles" + ON profiles FOR ALL + USING (auth.jwt() ->> 'role' = 'service_role'); + +CREATE POLICY "Service role full access responses" + ON responses FOR ALL + USING (auth.jwt() ->> 'role' = 'service_role'); + +CREATE POLICY "Service role full access actions" + ON actions FOR ALL + USING (auth.jwt() ->> 'role' = 'service_role'); + +CREATE POLICY "Service role full access sharing_events" + ON sharing_events FOR ALL + USING (auth.jwt() ->> 'role' = 'service_role'); From a56a7166623dff5ef71d8d8514e748d3285b8145 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 21 Oct 2025 13:07:58 +0100 Subject: [PATCH 2/7] feat(auth): implement magic link authentication with profile completion - Add Supabase SSR integration with @supabase/ssr package - Create hooks.server.ts for session management via HTTP-only cookies - Implement magic link login flow with email OTP - Add auth callback handler supporting both PKCE and token_hash flows - Create ProfileCompletionModal for first-time user onboarding - Collect user name and optional line manager details on signup - Add RLS policies migration to support INSERT operations with WITH CHECK - Fix upsertResponse to use native Supabase upsert with onConflict - Remove test user selection UI and context providers - Add profile loading from authenticated session in dashboard layout - Display logged-in user name in header - Disable email button when no questions answered - Create custom branded magic link email template - Add error handling for expired/invalid magic links - Update deployment script for production auth configuration --- CLAUDE.md | 23 +++ package-lock.json | 40 +++-- package.json | 3 +- scripts/deploy.sh | 11 +- src/app.d.ts | 11 +- src/hooks.server.ts | 34 ++++ src/lib/components/layouts/Header.svelte | 35 +++- .../ui/ProfileCompletionModal.svelte | 135 ++++++++++++++++ src/lib/components/views/Dash.svelte | 68 +------- src/lib/services/database/responses.ts | 42 +++-- src/lib/services/supabaseClient.ts | 4 +- src/routes/+layout.svelte | 58 ------- src/routes/+page.svelte | 124 +++++++++++--- src/routes/auth/callback/+server.ts | 38 +++++ src/routes/dashboard/+layout.server.ts | 38 +++++ src/routes/dashboard/+layout.svelte | 89 ++++++++++ src/routes/dashboard/+page.svelte | 26 +++ ...1021115933_fix_rls_policies_for_insert.sql | 63 ++++++++ supabase/templates/magic_link.html | 153 ++++++++++++++++++ 19 files changed, 815 insertions(+), 180 deletions(-) create mode 100644 src/hooks.server.ts create mode 100644 src/lib/components/ui/ProfileCompletionModal.svelte create mode 100644 src/routes/auth/callback/+server.ts create mode 100644 src/routes/dashboard/+layout.server.ts create mode 100644 src/routes/dashboard/+layout.svelte create mode 100644 src/routes/dashboard/+page.svelte create mode 100644 supabase/migrations/20251021115933_fix_rls_policies_for_insert.sql create mode 100644 supabase/templates/magic_link.html diff --git a/CLAUDE.md b/CLAUDE.md index 5b98f43..e27fb54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,6 +233,29 @@ Before submitting any code, ensure the following steps are completed: ## Deployment (Vercel) +### IMPORTANT: Production Deployment Rules + +⚠️ **CRITICAL**: Production (`https://lift02.vercel.app`) is MANUALLY controlled. DO NOT deploy to production accidentally. + +**Deployment Commands:** + +```bash +# PREVIEW deployment (safe - for testing) +npm run deploy +# or +npx vercel + +# PRODUCTION deployment (MANUAL ONLY - requires explicit approval) +npx vercel --prod +``` + +**Safe Workflow:** +1. Work on feature branches (e.g., `feat/implement_auth`) +2. Use `npm run deploy` to create preview deployments for testing +3. Preview deployments get unique URLs like `https://lift02-xyz123.vercel.app` +4. Only deploy to production after explicit approval +5. Production is controlled from `main` branch only + ### Environment Variables Setup Set these environment variables in Vercel dashboard (Settings > Environment Variables): diff --git a/package-lock.json b/package-lock.json index 1d934d4..fbca370 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "workwise", - "version": "0.6.008", + "version": "0.6.026", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "workwise", - "version": "0.6.008", + "version": "0.6.026", "dependencies": { "@fontsource/metropolis": "^5.2.5", + "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.49.8", "@tailwindcss/vite": "^4.1.10", "daisyui": "^5.0.43", @@ -37,7 +38,7 @@ "prettier-plugin-sql": "^0.19.1", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.12", - "supabase": "^2.40.7", + "supabase": "^2.53.6", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "svelte-hero-icons": "^5.2.0", @@ -1426,6 +1427,25 @@ "ws": "^8.18.2" } }, + "node_modules/@supabase/ssr": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.7.0.tgz", + "integrity": "sha512-G65t5EhLSJ5c8hTCcXifSL9Q/ZRXvqgXeNo+d3P56f4U1IxwTqjB64UfmfixvmMcjuxnq2yGqEWVJqUcO+AzAg==", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.43.4" + } + }, + "node_modules/@supabase/ssr/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "engines": { + "node": ">=18" + } + }, "node_modules/@supabase/storage-js": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", @@ -5915,16 +5935,16 @@ "license": "MIT" }, "node_modules/supabase": { - "version": "2.45.5", - "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.45.5.tgz", - "integrity": "sha512-/toOX6bHYx2TUNA5AtlzrKKfvctbLQ8R6QqvUCAT20KtZOkR14HpBYav3TNDaU5owPeB58cT5Uftvxw36Tb95A==", + "version": "2.53.6", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.53.6.tgz", + "integrity": "sha512-tvMSykcxBaFm2SAKx93h6h4HjfJnWZV0kxO2P3i491Mtnu/95knLjSxr5gu7/tAJtPFuEMG/0m1kNGOHPSE6YA==", "dev": true, "hasInstallScript": true, "dependencies": { "bin-links": "^5.0.0", "https-proxy-agent": "^7.0.2", "node-fetch": "^3.3.2", - "tar": "7.4.4" + "tar": "7.5.1" }, "bin": { "supabase": "bin/supabase" @@ -6095,9 +6115,9 @@ } }, "node_modules/tar": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.4.tgz", - "integrity": "sha512-O1z7ajPkjTgEgmTGz0v9X4eqeEXTDREPTO77pVC1Nbs86feBU1Zhdg+edzavPmYW1olxkwsqA2v4uOw6E8LeDg==", + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", + "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", diff --git a/package.json b/package.json index 623f7d2..841d29c 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "prettier-plugin-sql": "^0.19.1", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.12", - "supabase": "^2.40.7", + "supabase": "^2.53.6", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "svelte-hero-icons": "^5.2.0", @@ -51,6 +51,7 @@ }, "dependencies": { "@fontsource/metropolis": "^5.2.5", + "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.49.8", "@tailwindcss/vite": "^4.1.10", "daisyui": "^5.0.43", diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 2b542ac..b786636 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -38,10 +38,13 @@ echo "📝 Committing version change..." git add "package.json" "src/lib/version.ts" git commit -m "chore: bump version to $NEW_VERSION" || echo "⚠️ No changes to commit" -echo "🏗️ Building and deploying to production..." +echo "🏗️ Building and deploying to PREVIEW (not production)..." -# Deploy to Vercel -npx vercel --prod +# Deploy to Vercel PREVIEW (not production) +# IMPORTANT: We deploy to preview only. Production is manually controlled. +npx vercel echo "🎉 Deployment completed! New version: $NEW_VERSION" -echo "🔗 Check your deployment at: https://lift02.vercel.app" \ No newline at end of file +echo "⚠️ This was deployed as a PREVIEW deployment (not production)" +echo "🔗 Check the deployment URL from the vercel output above" +echo "📌 To deploy to production, use: npx vercel --prod (manual only!)" \ No newline at end of file diff --git a/src/app.d.ts b/src/app.d.ts index da08e6d..d725565 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,10 +1,17 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces +import type { Session, SupabaseClient } from '@supabase/supabase-js'; + declare global { namespace App { // interface Error {} - // interface Locals {} - // interface PageData {} + interface Locals { + supabase: SupabaseClient; + getSession: () => Promise; + } + interface PageData { + session: Session | null; + } // interface PageState {} // interface Platform {} } diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..d7cbe91 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,34 @@ +import { createServerClient } from '@supabase/ssr'; +import { type Handle } from '@sveltejs/kit'; +import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; + +export const handle: Handle = async ({ event, resolve }) => { + event.locals.supabase = createServerClient( + PUBLIC_SUPABASE_URL, + PUBLIC_SUPABASE_ANON_KEY, + { + cookies: { + get: (key) => event.cookies.get(key), + set: (key, value, options) => { + event.cookies.set(key, value, { ...options, path: '/' }); + }, + remove: (key, options) => { + event.cookies.delete(key, { ...options, path: '/' }); + } + } + } + ); + + event.locals.getSession = async () => { + const { + data: { session } + } = await event.locals.supabase.auth.getSession(); + return session; + }; + + return resolve(event, { + filterSerializedResponseHeaders(name) { + return name === 'content-range'; + } + }); +}; diff --git a/src/lib/components/layouts/Header.svelte b/src/lib/components/layouts/Header.svelte index 3bc4e71..819446c 100644 --- a/src/lib/components/layouts/Header.svelte +++ b/src/lib/components/layouts/Header.svelte @@ -4,6 +4,7 @@ import Tooltip from '../ui/Tooltip.svelte'; import { Icon, Envelope } from 'svelte-hero-icons'; import { version } from '$lib/version'; + import { getUserResponses } from '$lib/services/database/responses'; const getApp = getContext<() => AppState>('getApp'); const app = $derived(getApp()); @@ -12,6 +13,21 @@ // Check if currently in email view const isInEmailView = $derived(app.view.name === 'email'); + + // Check if user has any answered questions + let hasAnsweredQuestions = $state(false); + + $effect(() => { + if (app.profile.id) { + getUserResponses(app.profile.id).then((result) => { + if (result.data) { + hasAnsweredQuestions = result.data.some(r => r.status === 'answered'); + } + }); + } else { + hasAnsweredQuestions = false; + } + }); const onProfileClick = () => { // setViewName('profile'); console.log('Profile Clicked'); @@ -113,16 +129,25 @@ {/if} -

Workwise

+
+

Workwise

+ {#if app.profile.name} + Logged in as: {app.profile.name} + {/if} +
{#if app.profile.id} - +
+ + + +{/if} diff --git a/src/lib/components/views/Dash.svelte b/src/lib/components/views/Dash.svelte index c3e38c2..ece251e 100644 --- a/src/lib/components/views/Dash.svelte +++ b/src/lib/components/views/Dash.svelte @@ -41,38 +41,6 @@ setList({ table, category }); setViewName('list'); }; - - // ========== TESTING ONLY - REMOVE WHEN DONE ========== - const getTestUsers = getContext<() => Profile[]>('getTestUsers'); - const setTestUser = getContext<(userId: string, userName: string) => Promise>('setTestUser'); - - const sortedTestUsers = $derived( - [...(getTestUsers() || [])] - .filter( - (user): user is Profile & { id: string; name: string } => - user.id !== null && user.name !== null && user.is_line_manager === false - ) - .sort((a, b) => a.id.localeCompare(b.id)) - ); - - let dropdownExpanded = $state(false); - let selectedUserId = $state(null); - - async function handleUserSelect(userId: string, userName: string) { - await setTestUser(userId, userName); - selectedUserId = userId; - console.log('Test user selected:', userName, userId); - - // Close dropdown after selection - dropdownExpanded = false; - const dropdown = document.activeElement as HTMLElement; - dropdown?.blur(); - } - - function toggleDropdown() { - dropdownExpanded = !dropdownExpanded; - } - // ======================================================
@@ -81,41 +49,7 @@
{#if app.profile.id == null}
- - - - - +
{:else} diff --git a/src/lib/services/database/responses.ts b/src/lib/services/database/responses.ts index ce83a38..cc62166 100644 --- a/src/lib/services/database/responses.ts +++ b/src/lib/services/database/responses.ts @@ -196,27 +196,47 @@ export async function updateResponse( /** * Create or update a response (upsert pattern) + * Uses PostgreSQL's ON CONFLICT to handle the unique constraint on (user_id, question_id) */ export async function upsertResponse( userId: string, questionId: string, data: Omit ): Result { - // Try to update first - const updateResult = await updateResponse(userId, questionId, data); + const upsertData = { + ...data, + user_id: userId, + question_id: questionId + }; - if (updateResult.data) { - // Update succeeded - return updateResult; + // Use Supabase's upsert with onConflict to handle unique constraint + const { data: response, error } = await supabase + .from('responses') + .upsert(upsertData, { + onConflict: 'user_id,question_id' + }) + .select() + .single(); + + if (error) { + return { data: null, error }; } - // Update failed, try to create - const createResult = await createResponse(userId, { - ...data, - question_id: questionId - }); + // Convert database type to tableMain type + const convertedData = response + ? { + id: response.id, + user_id: response.user_id || '', + question_id: response.question_id || '', + response_text: response.response_text || undefined, + status: response.status as 'answered' | 'skipped', + visibility: response.visibility as 'public' | 'private', + created_at: response.created_at || undefined, + updated_at: response.updated_at || undefined + } + : null; - return createResult; + return { data: convertedData, error: null }; } /** diff --git a/src/lib/services/supabaseClient.ts b/src/lib/services/supabaseClient.ts index d4b74b1..82b1fa1 100644 --- a/src/lib/services/supabaseClient.ts +++ b/src/lib/services/supabaseClient.ts @@ -1,7 +1,7 @@ import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; -import { createClient } from '@supabase/supabase-js'; +import { createBrowserClient } from '@supabase/ssr'; const supabaseUrl = PUBLIC_SUPABASE_URL; const supabaseKey = PUBLIC_SUPABASE_ANON_KEY; -export const supabase = createClient(supabaseUrl, supabaseKey); +export const supabase = createBrowserClient(supabaseUrl, supabaseKey); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 23a46c2..3cfcb85 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -29,10 +29,6 @@ import Header from '$lib/components/layouts/Header.svelte'; import Footer from '$lib/components/layouts/Footer.svelte'; - // ========== TESTING ONLY - REMOVE WHEN DONE ========== - import { getAllProfiles, getProfile } from '$lib/services/database/profiles'; - import { onMount } from 'svelte'; - // ====================================================== // Dev Mode let devMode = $state(false); @@ -42,10 +38,6 @@ devMode = !devMode; }); - // ========== TESTING ONLY - REMOVE WHEN DONE ========== - let testUsers = $state([]); - // ====================================================== - // =1 App State let appState = $state({ profile: { @@ -158,56 +150,6 @@ appState.view.name = newView; }); - // ========== TESTING ONLY - REMOVE WHEN DONE ========== - // Add context for test users - setContext('getTestUsers', () => testUsers); - setContext('setTestUser', async (userId: string, userName: string) => { - // Set basic info immediately for UI responsiveness - appState.profile.id = userId; - appState.profile.name = userName; - - // Load full profile data including preferences - try { - const result = await getProfile(userId); - if (result.data) { - appState.profile = { - id: result.data.user_id, - name: result.data.name, - is_line_manager: result.data.is_line_manager, - preferences: (result.data.preferences as UserPreferences) || {} - }; - } - } catch (error) { - console.error('Failed to load profile preferences:', error); - } - }); - - // Fetch all profiles for testing dropdown - onMount(() => { - let cancelled = false; - - (async () => { - const result = await getAllProfiles(); - if (!cancelled) { - if (result.data) { - testUsers = result.data.map(user => ({ - ...user, - id: user.user_id, - preferences: (user.preferences as UserPreferences) || {} - })); - console.log('Test users loaded:', testUsers.length); - } else if (result.error) { - console.warn('Could not load test users for development:', result.error.message); - } - } - })(); - - return () => { - cancelled = true; - }; - }); - // ====================================================== - // =1 Child Props let { children } = $props(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c5ce6fa..29bb4ba 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,26 +1,110 @@ -
- {#if view === 'dash'} - - {:else if view === 'list'} - - {:else if view === 'detail'} - - {:else if view === 'email'} - - {:else} -
No view selected
- {/if} +
+
+
+

LIFT Digital Workplace Passport

+ + {#if !sent} +

Sign in with your email to access your workplace passport

+ +
+
+ + +
+ + {#if error} +
+ {error} +
+ {/if} + + +
+ {:else} +
+ + + +
+

Check your email!

+
+ We've sent a magic link to {email}. Click the link to sign in. +
+
+
+ +

+ Didn't receive the email? Check your spam folder or try again. +

+ + + {/if} +
+
diff --git a/src/routes/auth/callback/+server.ts b/src/routes/auth/callback/+server.ts new file mode 100644 index 0000000..ac06ffb --- /dev/null +++ b/src/routes/auth/callback/+server.ts @@ -0,0 +1,38 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, locals: { supabase } }) => { + const token_hash = url.searchParams.get('token_hash'); + const type = url.searchParams.get('type'); + const code = url.searchParams.get('code'); + + console.log('🔐 Auth callback triggered'); + console.log('📝 URL params:', { token_hash: !!token_hash, type, code: !!code }); + + // Handle PKCE flow (code) + if (code) { + const { data, error } = await supabase.auth.exchangeCodeForSession(code); + console.log('🔄 PKCE exchange result:', { session: !!data.session, error }); + + if (error) { + console.error('❌ PKCE exchange error:', error); + throw redirect(303, '/dashboard#error=' + error.message); + } + } + // Handle magic link flow (token_hash) + else if (token_hash && type) { + const { data, error } = await supabase.auth.verifyOtp({ + token_hash, + type: type as any + }); + console.log('🔄 OTP verify result:', { session: !!data.session, error }); + + if (error) { + console.error('❌ OTP verify error:', error); + throw redirect(303, `/dashboard#error=access_denied&error_code=${error.code || 'unknown'}&error_description=${encodeURIComponent(error.message)}`); + } + } + + console.log('✅ Redirecting to dashboard'); + throw redirect(303, '/dashboard'); +}; diff --git a/src/routes/dashboard/+layout.server.ts b/src/routes/dashboard/+layout.server.ts new file mode 100644 index 0000000..aacac55 --- /dev/null +++ b/src/routes/dashboard/+layout.server.ts @@ -0,0 +1,38 @@ +import { redirect } from '@sveltejs/kit'; +import { dev } from '$app/environment'; +import { getProfile } from '$lib/services/database/profiles'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals: { getSession } }) => { + const session = await getSession(); + + // In production, require authentication + // In dev mode, allow access for testing with test users + if (!session && !dev) { + throw redirect(303, '/'); + } + + // Load user profile if authenticated + let profile = null; + if (session?.user?.id) { + console.log('🔍 Looking for profile with user_id:', session.user.id); + console.log('📧 User email:', session.user.email); + + const result = await getProfile(session.user.id); + console.log('📊 Profile query result:', result); + + if (result.data) { + profile = { + id: result.data.user_id, + name: result.data.name, + is_line_manager: result.data.is_line_manager, + preferences: result.data.preferences || {} + }; + console.log('✅ Profile loaded:', profile); + } else if (result.error) { + console.error('❌ Profile error:', result.error); + } + } + + return { session, profile }; +}; diff --git a/src/routes/dashboard/+layout.svelte b/src/routes/dashboard/+layout.svelte new file mode 100644 index 0000000..3e300eb --- /dev/null +++ b/src/routes/dashboard/+layout.svelte @@ -0,0 +1,89 @@ + + +{#if authError} +
+
+
+

Authentication Error

+

+ {#if authError.code === 'otp_expired'} + Your magic link has expired. Magic links are only valid for 1 hour. + {:else if authError.code === 'access_denied'} + Access was denied. The link may be invalid or has already been used. + {:else} + {authError.description.replace(/\+/g, ' ')} + {/if} +

+
+ +
+
+
+
+{:else} + {@render children()} + +{/if} diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte new file mode 100644 index 0000000..c5ce6fa --- /dev/null +++ b/src/routes/dashboard/+page.svelte @@ -0,0 +1,26 @@ + + +
+ {#if view === 'dash'} + + {:else if view === 'list'} + + {:else if view === 'detail'} + + {:else if view === 'email'} + + {:else} +
No view selected
+ {/if} +
diff --git a/supabase/migrations/20251021115933_fix_rls_policies_for_insert.sql b/supabase/migrations/20251021115933_fix_rls_policies_for_insert.sql new file mode 100644 index 0000000..7fed74a --- /dev/null +++ b/supabase/migrations/20251021115933_fix_rls_policies_for_insert.sql @@ -0,0 +1,63 @@ +-- Fix RLS policies to support INSERT operations +-- The previous policies used USING clause for ALL operations, +-- but INSERT requires WITH CHECK clause instead + +-- Drop old policies +DROP POLICY IF EXISTS "Users can manage own responses" ON responses; +DROP POLICY IF EXISTS "Users can manage own actions" ON actions; +DROP POLICY IF EXISTS "Users can manage own sharing events" ON sharing_events; + +-- Recreate policies with proper INSERT support +-- Responses: Users can read/update/delete their own, and insert with their user_id +CREATE POLICY "Users can select own responses" + ON responses FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own responses" + ON responses FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own responses" + ON responses FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can delete own responses" + ON responses FOR DELETE + USING (auth.uid() = user_id); + +-- Actions: Users can read/update/delete their own, and insert with their user_id +CREATE POLICY "Users can select own actions" + ON actions FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own actions" + ON actions FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own actions" + ON actions FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can delete own actions" + ON actions FOR DELETE + USING (auth.uid() = user_id); + +-- Sharing events: Users can read/update/delete their own, and insert with their user_id +CREATE POLICY "Users can select own sharing events" + ON sharing_events FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own sharing events" + ON sharing_events FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own sharing events" + ON sharing_events FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can delete own sharing events" + ON sharing_events FOR DELETE + USING (auth.uid() = user_id); diff --git a/supabase/templates/magic_link.html b/supabase/templates/magic_link.html new file mode 100644 index 0000000..48924a8 --- /dev/null +++ b/supabase/templates/magic_link.html @@ -0,0 +1,153 @@ + + + + + + LIFT - Sign In to Workwise + + + + + + From b6da058162d31fccad8bfdabfc20c9d36683d743 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 21 Oct 2025 17:11:32 +0100 Subject: [PATCH 3/7] feat(auth): improve magic link UX and add profile settings - Update landing page to clarify login vs signup flow - Add envelope icon to "Send magic link" button for clarity - Improve landing page positioning (items-start with pt-20) - Create ProfileSettingsModal for managing user profile - Add ProfileButton to footer for accessing profile settings - Update magic link email template with brand colors and LIFT logo - Optimize email template for client compatibility (table-based layout) - Remove flexbox and margins from email template - Add consistent Tooltip components across ActionsCRUD, QuestionCard, UndoButton - Fix TypeScript implicit 'any' types in Dash.svelte - Regenerate database types with line_manager fields --- src/lib/components/cards/QuestionCard.svelte | 43 +- src/lib/components/layouts/Footer.svelte | 3 + src/lib/components/layouts/Header.svelte | 9 +- src/lib/components/ui/ActionsCRUD.svelte | 18 +- src/lib/components/ui/ProfileButton.svelte | 37 ++ .../ui/ProfileCompletionModal.svelte | 12 +- .../components/ui/ProfileSettingsModal.svelte | 196 ++++++ src/lib/components/ui/UndoButton.svelte | 21 +- src/lib/components/views/Dash.svelte | 8 +- src/lib/services/database/types.ts | 586 ++++++++++++++++-- src/routes/+page.svelte | 10 +- supabase/templates/magic_link.html | 271 ++++---- 12 files changed, 982 insertions(+), 232 deletions(-) create mode 100644 src/lib/components/ui/ProfileButton.svelte create mode 100644 src/lib/components/ui/ProfileSettingsModal.svelte diff --git a/src/lib/components/cards/QuestionCard.svelte b/src/lib/components/cards/QuestionCard.svelte index 75f56f4..2723a3b 100644 --- a/src/lib/components/cards/QuestionCard.svelte +++ b/src/lib/components/cards/QuestionCard.svelte @@ -4,6 +4,7 @@ import ToggleStatus from '../ui/ToggleStatus.svelte'; import SaveStatus from '../ui/SaveStatus.svelte'; import UndoButton from '../ui/UndoButton.svelte'; + import Tooltip from '../ui/Tooltip.svelte'; import type { QuestionConnections, RowId, ViewName } from '$lib/types/appState'; import { getQuestionConnections } from '$lib/utils/getContent.svelte'; import { getContext } from 'svelte'; @@ -296,19 +297,20 @@ >
- + + +
{#if saveError} @@ -324,13 +326,14 @@ {#if connectionDetails.responseId}
- + + +
{/if} {:else} diff --git a/src/lib/components/layouts/Footer.svelte b/src/lib/components/layouts/Footer.svelte index 13ce57b..4402540 100644 --- a/src/lib/components/layouts/Footer.svelte +++ b/src/lib/components/layouts/Footer.svelte @@ -4,6 +4,7 @@ import { getContext } from 'svelte'; import FontSizeControl from '../ui/FontSizeControl.svelte'; import HelpButton from '../ui/HelpButton.svelte'; + import ProfileButton from '../ui/ProfileButton.svelte'; let { devMode } = $props(); @@ -16,6 +17,8 @@
+ +
diff --git a/src/lib/components/layouts/Header.svelte b/src/lib/components/layouts/Header.svelte index 819446c..7881f48 100644 --- a/src/lib/components/layouts/Header.svelte +++ b/src/lib/components/layouts/Header.svelte @@ -17,9 +17,14 @@ // Check if user has any answered questions let hasAnsweredQuestions = $state(false); + // Re-check responses whenever view changes or profile loads $effect(() => { - if (app.profile.id) { - getUserResponses(app.profile.id).then((result) => { + // Track view name and profile id to trigger re-check + const viewName = app.view.name; + const profileId = app.profile.id; + + if (profileId) { + getUserResponses(profileId).then((result) => { if (result.data) { hasAnsweredQuestions = result.data.some(r => r.status === 'answered'); } diff --git a/src/lib/components/ui/ActionsCRUD.svelte b/src/lib/components/ui/ActionsCRUD.svelte index fb99fab..6920670 100644 --- a/src/lib/components/ui/ActionsCRUD.svelte +++ b/src/lib/components/ui/ActionsCRUD.svelte @@ -9,6 +9,7 @@ import type { Action } from '$lib/types/tableMain'; import type { AppState } from '$lib/types/appState'; import ConfirmModal from './ConfirmModal.svelte'; + import Tooltip from './Tooltip.svelte'; interface Props { responseId: string | null; @@ -320,14 +321,15 @@
{:else} - + + + {#if !responseId}

💡 Save your response first to add actions diff --git a/src/lib/components/ui/ProfileButton.svelte b/src/lib/components/ui/ProfileButton.svelte new file mode 100644 index 0000000..b86526c --- /dev/null +++ b/src/lib/components/ui/ProfileButton.svelte @@ -0,0 +1,37 @@ + + +{#if hasProfile} + + + + + +{/if} diff --git a/src/lib/components/ui/ProfileCompletionModal.svelte b/src/lib/components/ui/ProfileCompletionModal.svelte index a39cc87..1c6a651 100644 --- a/src/lib/components/ui/ProfileCompletionModal.svelte +++ b/src/lib/components/ui/ProfileCompletionModal.svelte @@ -1,6 +1,6 @@ + +{#if show} +

+{/if} diff --git a/src/lib/components/ui/UndoButton.svelte b/src/lib/components/ui/UndoButton.svelte index 0888d25..ee2190d 100644 --- a/src/lib/components/ui/UndoButton.svelte +++ b/src/lib/components/ui/UndoButton.svelte @@ -1,4 +1,6 @@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/src/lib/components/views/Dash.svelte b/src/lib/components/views/Dash.svelte index ece251e..4bbb274 100644 --- a/src/lib/components/views/Dash.svelte +++ b/src/lib/components/views/Dash.svelte @@ -8,7 +8,7 @@ import { getQuestions } from '$lib/services/database/questions'; import { getResources } from '$lib/services/database/resources'; import { getUserResponses } from '$lib/services/database/responses'; - import type { Question } from '$lib/types/tableMain'; + import type { Question, Response } from '$lib/types/tableMain'; import type { AppState, ItemCategory, List, TableName, ViewName, Profile } from '$lib/types/appState'; import { makePretty } from '$lib/utils/textTools'; @@ -93,10 +93,10 @@ {#if questionsResult.data} {#each extractCategories(questionsResult.data) as category} {@const table = 'questions'} - {@const categoryQuestions = questionsResult.data.filter(q => q.category === category.raw)} + {@const categoryQuestions = questionsResult.data.filter((q: Question) => q.category === category.raw)} {@const total = categoryQuestions.length} - {@const answeredQuestionIds = new Set(responsesResult?.data?.map(r => r.question_id) || [])} - {@const completed = categoryQuestions.filter(q => answeredQuestionIds.has(q.id)).length} + {@const answeredQuestionIds = new Set(responsesResult?.data?.map((r: Response) => r.question_id) || [])} + {@const completed = categoryQuestions.filter((q: Question) => answeredQuestionIds.has(q.id)).length} {@const completionText = `${completed}/${total}`} = Promise<{ - data: T | null; - error: Error | null; -}>; - -export type DbResultMany = Promise<{ - data: T[] | null; - error: Error | null; -}>; - -// Common error types -export class DatabaseError extends Error { - constructor( - message: string, - public code?: string - ) { - super(message); - this.name = 'DatabaseError'; - } +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: { + actions: { + Row: { + created_at: string | null + description: string | null + id: string + response_id: string | null + status: string + type: string + updated_at: string | null + user_id: string | null + } + Insert: { + created_at?: string | null + description?: string | null + id?: string + response_id?: string | null + status?: string + type: string + updated_at?: string | null + user_id?: string | null + } + Update: { + created_at?: string | null + description?: string | null + id?: string + response_id?: string | null + status?: string + type?: string + updated_at?: string | null + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "actions_response_id_fkey" + columns: ["response_id"] + isOneToOne: false + referencedRelation: "responses" + referencedColumns: ["id"] + }, + ] + } + line_managers: { + Row: { + created_at: string | null + email: string | null + id: string + line_manager_id: string + organization_id: string + updated_at: string | null + } + Insert: { + created_at?: string | null + email?: string | null + id?: string + line_manager_id: string + organization_id: string + updated_at?: string | null + } + Update: { + created_at?: string | null + email?: string | null + id?: string + line_manager_id?: string + organization_id?: string + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "line_managers_line_manager_id_fkey" + columns: ["line_manager_id"] + isOneToOne: true + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "line_managers_organization_id_fkey" + columns: ["organization_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } + organizations: { + Row: { + created_at: string | null + id: string + name: string + updated_at: string | null + } + Insert: { + created_at?: string | null + id?: string + name: string + updated_at?: string | null + } + Update: { + created_at?: string | null + id?: string + name?: string + updated_at?: string | null + } + Relationships: [] + } + profiles: { + Row: { + email: string | null + id: string + inserted_at: string | null + is_line_manager: boolean | null + job_title: string | null + line_manager: string | null + line_manager_email: string | null + line_manager_name: string | null + name: string | null + preferences: Json + pronouns: string[] | null + updated_at: string | null + user_id: string | null + } + Insert: { + email?: string | null + id?: string + inserted_at?: string | null + is_line_manager?: boolean | null + job_title?: string | null + line_manager?: string | null + line_manager_email?: string | null + line_manager_name?: string | null + name?: string | null + preferences?: Json + pronouns?: string[] | null + updated_at?: string | null + user_id?: string | null + } + Update: { + email?: string | null + id?: string + inserted_at?: string | null + is_line_manager?: boolean | null + job_title?: string | null + line_manager?: string | null + line_manager_email?: string | null + line_manager_name?: string | null + name?: string | null + preferences?: Json + pronouns?: string[] | null + updated_at?: string | null + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "profiles_line_manager_fkey" + columns: ["line_manager"] + isOneToOne: false + referencedRelation: "line_managers" + referencedColumns: ["id"] + }, + ] + } + questions: { + Row: { + category: string + id: string + order: number + preview: string | null + question_text: string + } + Insert: { + category: string + id?: string + order: number + preview?: string | null + question_text: string + } + Update: { + category?: string + id?: string + order?: number + preview?: string | null + question_text?: string + } + Relationships: [] + } + resources: { + Row: { + created_at: string | null + description: string | null + id: string + title: string + updated_at: string | null + url: string | null + } + Insert: { + created_at?: string | null + description?: string | null + id?: string + title: string + updated_at?: string | null + url?: string | null + } + Update: { + created_at?: string | null + description?: string | null + id?: string + title?: string + updated_at?: string | null + url?: string | null + } + Relationships: [] + } + responses: { + Row: { + created_at: string | null + id: string + question_id: string | null + response_text: string | null + status: string | null + updated_at: string | null + user_id: string | null + visibility: string + } + Insert: { + created_at?: string | null + id?: string + question_id?: string | null + response_text?: string | null + status?: string | null + updated_at?: string | null + user_id?: string | null + visibility?: string + } + Update: { + created_at?: string | null + id?: string + question_id?: string | null + response_text?: string | null + status?: string | null + updated_at?: string | null + user_id?: string | null + visibility?: string + } + Relationships: [ + { + foreignKeyName: "responses_question_id_fkey" + columns: ["question_id"] + isOneToOne: false + referencedRelation: "questions" + referencedColumns: ["id"] + }, + ] + } + responses_backup: { + Row: { + created_at: string | null + id: string | null + question_id: string | null + response_text: string | null + status: string | null + updated_at: string | null + user_id: string | null + version: number | null + visibility: string | null + } + Insert: { + created_at?: string | null + id?: string | null + question_id?: string | null + response_text?: string | null + status?: string | null + updated_at?: string | null + user_id?: string | null + version?: number | null + visibility?: string | null + } + Update: { + created_at?: string | null + id?: string | null + question_id?: string | null + response_text?: string | null + status?: string | null + updated_at?: string | null + user_id?: string | null + version?: number | null + visibility?: string | null + } + Relationships: [] + } + sharing_event_actions: { + Row: { + action_id: string | null + id: string + sharing_event_id: string | null + } + Insert: { + action_id?: string | null + id?: string + sharing_event_id?: string | null + } + Update: { + action_id?: string | null + id?: string + sharing_event_id?: string | null + } + Relationships: [ + { + foreignKeyName: "sharing_event_actions_action_id_fkey" + columns: ["action_id"] + isOneToOne: false + referencedRelation: "actions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "sharing_event_actions_sharing_event_id_fkey" + columns: ["sharing_event_id"] + isOneToOne: false + referencedRelation: "sharing_events" + referencedColumns: ["id"] + }, + ] + } + sharing_event_responses: { + Row: { + id: string + response_id: string | null + sharing_event_id: string | null + } + Insert: { + id?: string + response_id?: string | null + sharing_event_id?: string | null + } + Update: { + id?: string + response_id?: string | null + sharing_event_id?: string | null + } + Relationships: [ + { + foreignKeyName: "sharing_event_responses_response_id_fkey" + columns: ["response_id"] + isOneToOne: false + referencedRelation: "responses" + referencedColumns: ["id"] + }, + { + foreignKeyName: "sharing_event_responses_sharing_event_id_fkey" + columns: ["sharing_event_id"] + isOneToOne: false + referencedRelation: "sharing_events" + referencedColumns: ["id"] + }, + ] + } + sharing_events: { + Row: { + id: string + message: string | null + recipient_email: string + shared_at: string | null + user_id: string | null + } + Insert: { + id?: string + message?: string | null + recipient_email: string + shared_at?: string | null + user_id?: string | null + } + Update: { + id?: string + message?: string | null + recipient_email?: string + shared_at?: string | null + user_id?: string | null + } + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +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 -// Common query options -export interface QueryOptions { - limit?: number; - offset?: number; - orderBy?: { - column: string; - ascending?: boolean; - }; +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 -// Common filter options -export interface FilterOptions { - status?: string; - visibility?: 'public' | 'private'; +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: {}, + }, +} as const + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 29bb4ba..ebd1793 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,6 @@ -
+

LIFT Digital Workplace Passport

{#if !sent} -

Sign in with your email to access your workplace passport

+

+ Enter your email to sign in or create a new account. We'll send you a magic link. +

@@ -70,6 +73,7 @@ Sending magic link... {:else} + Send magic link {/if} @@ -92,7 +96,7 @@

Check your email!

- We've sent a magic link to {email}. Click the link to sign in. + We've sent a magic link to {email}. Click the link to continue.
diff --git a/supabase/templates/magic_link.html b/supabase/templates/magic_link.html index 48924a8..82a50cb 100644 --- a/supabase/templates/magic_link.html +++ b/supabase/templates/magic_link.html @@ -1,153 +1,134 @@ - + - - - - LIFT - Sign In to Workwise - - - -