diff --git a/TEST_GUIDE.md b/TEST_GUIDE.md index 2afd15a..ca3006c 100644 --- a/TEST_GUIDE.md +++ b/TEST_GUIDE.md @@ -40,3 +40,18 @@ Please test each step in order and report any errors (console errors, wrong text 23. Open a new **private/incognito** window at **http://localhost:5173/recover** 24. Enter the same name (`Max`) and the PIN you copied 25. ✅ Expected: You're redirected to the feed with the same account + +### Step 8 — Upload rate-limit auto-retry +26. Upload more than 20 photos in one hour to trigger the rate limit +27. ✅ Expected: When the limit is hit, remaining items stay **Wartend** (not error) +28. ✅ Expected: An amber banner appears in the queue: "Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt." +29. ✅ Expected: The countdown ticks down and uploads resume automatically when it reaches 0 + +### Step 9 — Name uniqueness (case-insensitive) +30. In a private/incognito window go to **http://localhost:5173/join** +31. Enter `max` or `MAX` — the same name already taken in Step 1 (different case) +32. ✅ Expected: Instead of creating a new account, an amber warning appears: „Max ist bereits vergeben." with name tips +33. ✅ Expected: A PIN input and **Anmelden** button appear, plus an **Anderen Namen wählen** button +34. Enter your PIN from Step 1 and click **Anmelden** +35. ✅ Expected: You're signed in to the existing `Max` account and redirected to the feed +36. Alternatively, click **Anderen Namen wählen** — ✅ Expected: the name input reappears with `max` pre-filled so you can edit it diff --git a/backend/migrations/007_user_name_unique.down.sql b/backend/migrations/007_user_name_unique.down.sql new file mode 100644 index 0000000..5048a06 --- /dev/null +++ b/backend/migrations/007_user_name_unique.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_user_event_name_ci; + +CREATE INDEX idx_user_event_name + ON "user"(event_id, display_name); diff --git a/backend/migrations/007_user_name_unique.up.sql b/backend/migrations/007_user_name_unique.up.sql new file mode 100644 index 0000000..ab0f361 --- /dev/null +++ b/backend/migrations/007_user_name_unique.up.sql @@ -0,0 +1,15 @@ +-- Deduplicate users with the same name (case-insensitive) per event, +-- keeping the oldest account so no real data is lost. +DELETE FROM "user" +WHERE id NOT IN ( + SELECT DISTINCT ON (event_id, LOWER(display_name)) id + FROM "user" + ORDER BY event_id, LOWER(display_name), created_at ASC +); + +-- Drop the old non-unique index (replaced below) +DROP INDEX IF EXISTS idx_user_event_name; + +-- Unique index enforces one account per name per event (case-insensitive) +CREATE UNIQUE INDEX idx_user_event_name_ci + ON "user" (event_id, LOWER(display_name)); diff --git a/backend/src/auth/handlers.rs b/backend/src/auth/handlers.rs index 75cac91..f082ed8 100644 --- a/backend/src/auth/handlers.rs +++ b/backend/src/auth/handlers.rs @@ -57,6 +57,14 @@ pub async fn join( ) .await?; + // Reject if a user with this name (case-insensitive) already exists + if User::name_taken(&state.pool, event.id, display_name).await? { + return Err(AppError::Conflict(format!( + "Der Name \"{}\" ist bereits vergeben.", + display_name + ))); + } + // Generate a 4-digit PIN let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32)); let pin_hash = diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs index 815b5ad..931b802 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -59,7 +59,7 @@ impl User { display_name: &str, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Self>( - "SELECT * FROM \"user\" WHERE event_id = $1 AND display_name = $2", + "SELECT * FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2)", ) .bind(event_id) .bind(display_name) @@ -67,6 +67,21 @@ impl User { .await } + pub async fn name_taken( + pool: &PgPool, + event_id: Uuid, + display_name: &str, + ) -> Result { + let row: (bool,) = sqlx::query_as( + "SELECT EXISTS(SELECT 1 FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2))", + ) + .bind(event_id) + .bind(display_name) + .fetch_one(pool) + .await?; + Ok(row.0) + } + pub async fn increment_failed_pin(pool: &PgPool, id: Uuid) -> Result { let row: (i16,) = sqlx::query_as( "UPDATE \"user\" diff --git a/frontend/src/routes/join/+page.svelte b/frontend/src/routes/join/+page.svelte index 81585aa..05282b4 100644 --- a/frontend/src/routes/join/+page.svelte +++ b/frontend/src/routes/join/+page.svelte @@ -10,6 +10,13 @@ let pin = $state(''); let copied = $state(false); + // Name-taken state — shown instead of the normal form + let nameTaken = $state(false); + let takenName = $state(''); + let recoveryPin = $state(''); + let recoveryError = $state(''); + let recoveryLoading = $state(false); + async function handleJoin() { if (!displayName.trim()) return; loading = true; @@ -26,7 +33,10 @@ pin = res.pin; showPinModal = true; } catch (e) { - if (e instanceof ApiError) { + if (e instanceof ApiError && e.code === 'conflict') { + takenName = displayName.trim(); + nameTaken = true; + } else if (e instanceof ApiError) { error = e.message; } else { error = 'Ein Fehler ist aufgetreten.'; @@ -36,6 +46,35 @@ } } + async function handleInlineRecover() { + if (recoveryPin.length < 4) return; + recoveryLoading = true; + recoveryError = ''; + try { + const res = await api.post<{ jwt: string; user_id: string }>( + '/recover', + { display_name: takenName, pin: recoveryPin.trim() } + ); + setAuth(res.jwt, recoveryPin.trim(), res.user_id, takenName); + goto('/feed'); + } catch (e) { + if (e instanceof ApiError) { + recoveryError = e.message; + } else { + recoveryError = 'Ein Fehler ist aufgetreten.'; + } + } finally { + recoveryLoading = false; + } + } + + function tryDifferentName() { + nameTaken = false; + recoveryPin = ''; + recoveryError = ''; + // Keep displayName so the user can edit it slightly + } + function copyPin() { navigator.clipboard.writeText(pin); copied = true; @@ -49,35 +88,85 @@
-

Willkommen!

-

Gib deinen Namen ein, um dem Event beizutreten.

-
{ e.preventDefault(); handleJoin(); }}> - + {#if nameTaken} + +
+

„{takenName}" ist bereits vergeben.

+

+ Wähle einen anderen Namen, z. B. einen Spitznamen oder füge deinen Nachnamen hinzu + („{takenName} M." oder „{takenName} aus Berlin"). +

+
- {#if error} -

{error}

- {/if} +

+ Falls du das bist, melde dich mit deinem PIN an: +

+ + { e.preventDefault(); handleInlineRecover(); }}> + + + {#if recoveryError} +

{recoveryError}

+ {/if} + + +
- -

- Schon dabei? - Mit PIN wiederherstellen -

+ {:else} + +

Willkommen!

+

Gib deinen Namen ein, um dem Event beizutreten.

+ +
{ e.preventDefault(); handleJoin(); }}> + + + {#if error} +

{error}

+ {/if} + + +
+ +

+ Schon dabei? + Mit PIN wiederherstellen +

+ {/if} +