Add event-admin password reset flow
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
{"id":"fotospiel-app-25q","title":"Security review: payments/webhooks code audit (signatures, idempotency, linkage)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:25.747336642+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:25.747336642+01:00"}
|
||||
{"id":"fotospiel-app-29o","title":"Paddle catalog sync: PackageResource sync status badges + timestamp","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:10.009385187+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:15.639525807+01:00","closed_at":"2026-01-01T16:01:15.639525807+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-2hq","title":"Security review: marketing/API controller+validation review","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:08.862737923+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:08.862737923+01:00"}
|
||||
{"id":"fotospiel-app-2yn","title":"Event-Admin: Reset link routing + notifications + tests","description":"Point password reset emails to event-admin reset page; add rate limiting and tests for the new flow.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T10:45:09.279245468+01:00","created_by":"soeren","updated_at":"2026-01-06T11:01:49.083154811+01:00","closed_at":"2026-01-06T11:01:49.083154811+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-33m","title":"Security review checklist: Guest PWA dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:40.730459361+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:40.730459361+01:00"}
|
||||
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
|
||||
@@ -71,6 +72,7 @@
|
||||
{"id":"fotospiel-app-fp3","title":"Security review: guest PWA code audit (join tokens, uploads, SW cache)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:14.493336661+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:14.493336661+01:00"}
|
||||
{"id":"fotospiel-app-g5o","title":"SEC-MS-04 Storage health widget in Super Admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:15.088501536+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:20.739996548+01:00","closed_at":"2026-01-01T15:53:20.739996548+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-g74","title":"Paddle migration: automated tests for checkout/webhooks/sync","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:34.795423009+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:40.467997776+01:00","closed_at":"2026-01-01T15:58:40.467997776+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-gfl","title":"Event-Admin: Forgot/Reset Password UI (Tamagui pages + routing)","description":"Add /event-admin/forgot-password + /event-admin/reset-password/{token} routes and Tamagui pages; include form, status/errors, and link from login.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T10:44:55.52396697+01:00","created_by":"soeren","updated_at":"2026-01-06T11:01:43.370661887+01:00","closed_at":"2026-01-06T11:01:43.370661887+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-gsv","title":"Localized SEO: validate hreflang via Search Console/Lighthouse","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:36.4821072+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:36.4821072+01:00"}
|
||||
{"id":"fotospiel-app-h5d","title":"Live Show: decide display access model (token link vs kiosk session)","description":"# Decision: Display access model (public player view)\n\n## Context\nWe need a *hands-off* playback surface for projectors/TVs (“Live Show player view”). It must be easy to open on any device, safe to share with staff, and revocable if leaked.\n\nWe already use tokenized public routes for the guest PWA (`/g/{token}`, `/e/{token}/{path?}` in `routes/web.php`). We also use Laravel signed URLs for some one-off documents (gift voucher print), but that pattern is not ideal for long-running displays.\n\n## Options\n### A) Token-only show link (recommended)\n- Admin generates a **random, unguessable token** per event: e.g. `/show/{token}`.\n- Player view is publicly accessible *only* with the token.\n- Admin can **rotate** token to invalidate all current displays.\n- Optional: add **expires_at** for temporary links (off by default for convenience).\n\n**Pros**\n- Fastest operator workflow; no login needed on projector device.\n- Matches existing public-token patterns in this repo.\n- Simple to rotate/kill in case of leakage.\n\n**Cons**\n- Anyone with the link can view the show (mitigated by rotation and not showing PII).\n\n### B) Admin-authenticated “kiosk session”\n- Projector device logs in as an admin (or receives a kiosk session after admin auth) and then shows the player view.\n\n**Pros**\n- Strong access control.\n\n**Cons**\n- Operationally painful at venues (login, MFA, account management).\n- Higher support burden and more failure points.\n\n### C) Temporary signed URL (Laravel signed routes)\n- Generate a time-limited signed URL and use that for the player.\n\n**Pros**\n- No DB token storage required.\n\n**Cons**\n- Long-running displays need long expiry; link rotation/revocation is awkward.\n- Not great UX when a signature expires mid-event.\n\n## Recommended decision\nChoose **Option A: Token-only show link**.\n\n### Security posture\n- Token is treated as a secret: do not log full token; never display it in error messages.\n- Public player view must not display PII (no guest names, no device IDs).\n- Add rate limiting middleware for the show endpoints (lightweight protection against brute force).\n\n## Operational workflow\n1. Admin enables Live Show for an event.\n2. Admin copies the show link and opens it on the playback device.\n3. If link leaks: admin clicks **Rotate link** → all old links stop working.\n\n## Implementation notes (to guide downstream work)\n- Add `events.live_show_token` (random 32+ bytes, base64url/hex) and `events.live_show_token_rotated_at`.\n- New public route e.g. `GET /show/{token}` → serves the player SPA shell.\n- New public API endpoint resolved by token (read-only): `GET /api/v1/live-show/{token}/state` (settings + initial photo batch).\n- Realtime channel uses token to subscribe (or resolves event id server-side after token validation).\n\n## Decision needed from product\n- Confirm whether we want optional **expires_at** (default: no expiry, rely on rotation).\n- Confirm if multiple simultaneous display devices are supported (default: yes; token works naturally).\n","acceptance_criteria":"- Issue records options A/B/C with pros/cons\\n- Clear recommendation (token-only) with security considerations\\n- Concrete implementation notes (DB fields, route shape, revocation workflow)\\n- Explicit product decisions listed (expiry + multi-screen)","notes":"Decision: token-only show link per event; no expiry by default; rely on rotation/invalidation. Multi-screen supported naturally via token.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T11:43:11.636746837+01:00","created_by":"soeren","updated_at":"2026-01-05T12:06:45.909441986+01:00","closed_at":"2026-01-05T12:06:45.909441986+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-h5d","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:43:43.272652233+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-hbt","title":"Moderation queue for guest content","description":"Queue for flagged guest content (photos, feedback). Bulk actions to hide/delete/resolve with audit.","notes":"Land the plane: tests run (FilamentPanelNavigationTest, PhotoModerationQueueTest, TenantFeedbackModerationQueueTest, TenantLifecycle*), migrations added for photo + feedback moderation, no follow-up blockers.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:37.777772819+01:00","updated_at":"2026-01-02T17:33:22.599440896+01:00","closed_at":"2026-01-02T17:33:22.599440896+01:00","close_reason":"Closed"}
|
||||
@@ -91,6 +93,7 @@
|
||||
{"id":"fotospiel-app-lnb","title":"SEC-GT-01 Hash join tokens + data migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:01.658868778+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:07.314317124+01:00","closed_at":"2026-01-01T15:52:07.314317124+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-lnf","title":"Remove legacy registration page assets","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T08:37:39.419274918+01:00","created_by":"soeren","updated_at":"2026-01-06T08:37:39.419274918+01:00"}
|
||||
{"id":"fotospiel-app-lqp","title":"Integrations health (Paddle/RevenueCat/webhooks)","description":"Health/status dashboard for payment and webhook integrations.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:25.197673148+01:00","updated_at":"2026-01-02T18:45:16.225355969+01:00","closed_at":"2026-01-02T18:45:16.225355969+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-m76","title":"Event-Admin: Forgot-Password API endpoint (tenant-auth)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T10:44:36.950627645+01:00","created_by":"soeren","updated_at":"2026-01-06T11:01:25.486182309+01:00","closed_at":"2026-01-06T11:01:25.486182309+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-mfz","title":"Playwright: paddle sandbox checkout test fails after login (coupon field missing)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T09:54:27.979177519+01:00","created_by":"soeren","updated_at":"2026-01-06T09:54:27.979177519+01:00"}
|
||||
{"id":"fotospiel-app-ml7","title":"SEC-GT-03 Tighten gallery/photo rate limits + alerting","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:18.593415508+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:18.593415508+01:00"}
|
||||
{"id":"fotospiel-app-mol","title":"Coupon ops: wire analytics into Matomo dashboard","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:27.722458747+01:00","created_by":"soeren","updated_at":"2026-01-02T23:28:18.178704873+01:00","closed_at":"2026-01-02T23:28:18.178704873+01:00","close_reason":"Closed"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
fotospiel-app-mfz
|
||||
fotospiel-app-2yn
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\TenantAuth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\TenantAdminForgotPasswordRequest;
|
||||
use App\Http\Requests\Auth\TenantAdminResetPasswordRequest;
|
||||
use App\Models\EventMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantAdminPasswordResetController extends Controller
|
||||
{
|
||||
public function requestLink(TenantAdminForgotPasswordRequest $request): JsonResponse
|
||||
{
|
||||
$email = $request->string('email')->trim()->value();
|
||||
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user || ! $this->canAccessEventAdmin($user)) {
|
||||
return $this->genericSuccessResponse();
|
||||
}
|
||||
|
||||
Password::sendResetLink([
|
||||
'email' => $email,
|
||||
]);
|
||||
|
||||
return $this->genericSuccessResponse();
|
||||
}
|
||||
|
||||
public function reset(TenantAdminResetPasswordRequest $request): JsonResponse
|
||||
{
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$this->ensureUserCanReset($user);
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->string('password')->value()),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
if ($status === Password::PasswordReset) {
|
||||
return response()->json([
|
||||
'status' => __($status),
|
||||
]);
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__($status)],
|
||||
]);
|
||||
}
|
||||
|
||||
private function genericSuccessResponse(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'status' => __('passwords.sent'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureUserCanReset(User $user): void
|
||||
{
|
||||
if ($this->canAccessEventAdmin($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [trans('auth.not_authorized')],
|
||||
]);
|
||||
}
|
||||
|
||||
private function canAccessEventAdmin(User $user): bool
|
||||
{
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($user->role === 'member' && $this->userHasCollaboratorMembership($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function userHasCollaboratorMembership(User $user): bool
|
||||
{
|
||||
if (! $user->tenant_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return EventMember::query()
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->where(function ($query) use ($user) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhere('email', $user->email);
|
||||
})
|
||||
->whereIn('status', ['active', 'invited'])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/Auth/TenantAdminForgotPasswordRequest.php
Normal file
28
app/Http/Requests/Auth/TenantAdminForgotPasswordRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TenantAdminForgotPasswordRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'email'],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/Auth/TenantAdminResetPasswordRequest.php
Normal file
31
app/Http/Requests/Auth/TenantAdminResetPasswordRequest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules;
|
||||
|
||||
class TenantAdminResetPasswordRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'token' => ['required', 'string'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class TenantAdminTokenRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'login' => ['required', 'string'],
|
||||
'login' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
@@ -27,10 +27,6 @@ class TenantAdminTokenRequest extends FormRequest
|
||||
{
|
||||
$login = $this->string('login')->trim()->value();
|
||||
|
||||
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
|
||||
return ['email' => $login, 'password' => $this->string('password')->value()];
|
||||
}
|
||||
|
||||
return ['username' => $login, 'password' => $this->string('password')->value()];
|
||||
return ['email' => $login, 'password' => $this->string('password')->value()];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,15 @@ class ResetPasswordNotification extends ResetPassword
|
||||
'expiresIn' => $expire,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function resetUrl($notifiable): string
|
||||
{
|
||||
$email = method_exists($notifiable, 'getEmailForPasswordReset')
|
||||
? $notifiable->getEmailForPasswordReset()
|
||||
: $notifiable->email;
|
||||
|
||||
$query = http_build_query(['email' => $email]);
|
||||
|
||||
return url("/event-admin/reset-password/{$this->token}?{$query}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ export const adminPath = (suffix = ''): string => `${ADMIN_BASE_PATH}${suffix}`;
|
||||
|
||||
export const ADMIN_LOGIN_PATH = adminPath('/login');
|
||||
export const ADMIN_PUBLIC_LANDING_PATH = ADMIN_LOGIN_PATH;
|
||||
export const ADMIN_PUBLIC_HELP_PATH = adminPath('/help');
|
||||
export const ADMIN_PUBLIC_FORGOT_PASSWORD_PATH = adminPath('/forgot-password');
|
||||
export const ADMIN_HOME_PATH = adminPath('/mobile/dashboard');
|
||||
export const ADMIN_DEFAULT_AFTER_LOGIN_PATH = ADMIN_HOME_PATH;
|
||||
export const ADMIN_LOGIN_START_PATH = adminPath('/start');
|
||||
|
||||
@@ -11,8 +11,33 @@
|
||||
"Steuere Aufgaben, Emotionen und Slideshows direkt vom Event aus."
|
||||
],
|
||||
"lead": "Du meldest dich über unser gesichertes Fotospiel-Login an und landest direkt im Event-Dashboard.",
|
||||
"panel_title": "Team Login für Fotospiel",
|
||||
"panel_copy": "Melde dich an, um Events zu planen, Fotos zu moderieren und Aufgaben im Fotospiel-Team zu koordinieren.",
|
||||
"panel_title": "Fotospiel.App Event Login",
|
||||
"panel_copy": "Melde dich an, um Events zu planen, Fotos zu moderieren und Aufgaben anzulegen.",
|
||||
"email": "E-Mail-Adresse",
|
||||
"email_hint": "Dein Benutzername ist deine E-Mail-Adresse.",
|
||||
"email_placeholder": "name@example.com",
|
||||
"forgot": {
|
||||
"title": "Passwort vergessen?",
|
||||
"copy": "Gib deine E-Mail-Adresse an, dann senden wir dir einen Reset-Link.",
|
||||
"submit": "Reset-Link senden",
|
||||
"loading": "Sende ...",
|
||||
"success": "Wenn dein Konto existiert, senden wir dir einen Reset-Link.",
|
||||
"back": "Zurück zum Login",
|
||||
"link": "Passwort vergessen?"
|
||||
},
|
||||
"reset": {
|
||||
"title": "Passwort neu setzen",
|
||||
"copy": "Lege ein neues Passwort für deinen Event-Admin-Zugang fest.",
|
||||
"password": "Neues Passwort",
|
||||
"password_placeholder": "••••••••",
|
||||
"password_confirm": "Passwort bestätigen",
|
||||
"password_confirm_placeholder": "••••••••",
|
||||
"submit": "Neues Passwort speichern",
|
||||
"loading": "Speichere ...",
|
||||
"success": "Passwort wurde aktualisiert.",
|
||||
"back": "Zurück zum Login",
|
||||
"missing_token": "Reset-Token fehlt."
|
||||
},
|
||||
"actions_title": "Wähle deine Anmeldemethode",
|
||||
"actions_copy": "Greife sicher mit deinem Fotospiel-Login oder deinem Google-Konto auf das Kunden-Dashboard zu.",
|
||||
"cta": "Mit Fotospiel-Login fortfahren",
|
||||
@@ -33,6 +58,32 @@
|
||||
},
|
||||
"return_hint": "Nach dem Anmelden leiten wir dich automatisch zurück.",
|
||||
"support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.",
|
||||
"faq": "Hilfe & FAQ",
|
||||
"help_title": "Hilfe für Event-Admins",
|
||||
"help_intro": "Hier findest du schnelle Antworten rund um Login, Zugriff und erste Schritte - auch ohne Anmeldung.",
|
||||
"help_faq_title": "Häufige Fragen vor dem Login",
|
||||
"help_faq_items": [
|
||||
{
|
||||
"question": "Ich habe keine Einladung erhalten.",
|
||||
"answer": "Bitte prüfe Spam/Unbekannt und bitte dein Event-Team, die Einladung erneut zu senden."
|
||||
},
|
||||
{
|
||||
"question": "Ich kann mich nicht einloggen.",
|
||||
"answer": "Der Login funktioniert nur mit deiner E-Mail-Adresse. Dein Benutzername ist deine E-Mail-Adresse."
|
||||
},
|
||||
{
|
||||
"question": "Mein Event ist nicht sichtbar.",
|
||||
"answer": "Prüfe, ob das Event veröffentlicht ist. Falls nicht, bitte den Owner, den Status zu ändern."
|
||||
},
|
||||
{
|
||||
"question": "Uploads hängen oder sind sehr langsam.",
|
||||
"answer": "Bitte kurz warten und die Verbindung prüfen. Bleibt das Problem, kontaktiere den Support."
|
||||
}
|
||||
],
|
||||
"help_support_title": "Support kontaktieren",
|
||||
"help_support_copy": "Schreib uns bei Fragen an",
|
||||
"help_support_cta": "E-Mail an Support senden",
|
||||
"help_back": "Zurück zum Login",
|
||||
"appearance_label": "Darstellung"
|
||||
},
|
||||
"redirecting": "Weiterleitung zum Login …",
|
||||
|
||||
@@ -13,6 +13,31 @@
|
||||
"lead": "Use our secure Fotospiel login and land directly in the event dashboard.",
|
||||
"panel_title": "Sign in",
|
||||
"panel_copy": "Sign in with your Fotospiel admin access. Sanctum personal access tokens and clear role permissions keep your account protected.",
|
||||
"email": "Email address",
|
||||
"email_hint": "Your username is your email address.",
|
||||
"email_placeholder": "name@example.com",
|
||||
"forgot": {
|
||||
"title": "Forgot your password?",
|
||||
"copy": "Enter your email address and we will send you a reset link.",
|
||||
"submit": "Send reset link",
|
||||
"loading": "Sending ...",
|
||||
"success": "If your account exists, we will send a reset link.",
|
||||
"back": "Back to login",
|
||||
"link": "Forgot your password?"
|
||||
},
|
||||
"reset": {
|
||||
"title": "Reset password",
|
||||
"copy": "Choose a new password for your event admin account.",
|
||||
"password": "New password",
|
||||
"password_placeholder": "••••••••",
|
||||
"password_confirm": "Confirm password",
|
||||
"password_confirm_placeholder": "••••••••",
|
||||
"submit": "Save new password",
|
||||
"loading": "Saving ...",
|
||||
"success": "Password updated.",
|
||||
"back": "Back to login",
|
||||
"missing_token": "Reset token missing."
|
||||
},
|
||||
"actions_title": "Choose your sign-in method",
|
||||
"actions_copy": "Access the customer dashboard securely with your Fotospiel login or your Google account.",
|
||||
"cta": "Continue with Fotospiel login",
|
||||
@@ -33,6 +58,32 @@
|
||||
},
|
||||
"return_hint": "After signing in you’ll be brought back automatically.",
|
||||
"support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.",
|
||||
"faq": "Help & FAQ",
|
||||
"help_title": "Help for event admins",
|
||||
"help_intro": "Quick answers about login, access, and first steps — even without signing in.",
|
||||
"help_faq_title": "Common questions before login",
|
||||
"help_faq_items": [
|
||||
{
|
||||
"question": "I did not receive an invite.",
|
||||
"answer": "Please check spam and ask your event team to resend the invitation."
|
||||
},
|
||||
{
|
||||
"question": "I cannot sign in.",
|
||||
"answer": "Sign-in works only with your email address. Your username equals your email."
|
||||
},
|
||||
{
|
||||
"question": "My event is not visible.",
|
||||
"answer": "Make sure the event is published. Ask the owner to adjust the status."
|
||||
},
|
||||
{
|
||||
"question": "Uploads are slow or stuck.",
|
||||
"answer": "Wait a moment and check the connection. If it persists, contact support."
|
||||
}
|
||||
],
|
||||
"help_support_title": "Contact support",
|
||||
"help_support_copy": "Email us at",
|
||||
"help_support_cta": "Send email to support",
|
||||
"help_back": "Back to login",
|
||||
"appearance_label": "Appearance"
|
||||
},
|
||||
"redirecting": "Redirecting to login …",
|
||||
|
||||
162
resources/js/admin/mobile/ForgotPasswordPage.tsx
Normal file
162
resources/js/admin/mobile/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { ADMIN_LOGIN_PATH } from '../constants';
|
||||
import { MobileCard } from './components/Primitives';
|
||||
import { MobileField, MobileInput } from './components/FormControls';
|
||||
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||
|
||||
type ResetResponse = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
async function requestPasswordReset(email: string): Promise<ResetResponse> {
|
||||
const response = await fetch('/api/v1/tenant-auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (response.status === 422) {
|
||||
const data = await response.json();
|
||||
const errors = data.errors ?? {};
|
||||
const flattened = Object.values(errors).flat();
|
||||
throw new Error(flattened.join(' ') || 'Validation failed');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Request failed.');
|
||||
}
|
||||
|
||||
return (await response.json()) as ResetResponse;
|
||||
}
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useTranslation('auth');
|
||||
const navigate = useNavigate();
|
||||
const { text, muted, border, surface, accentSoft, primary, dangerBg, dangerText } = useAdminTheme();
|
||||
const [email, setEmail] = React.useState('');
|
||||
const [status, setStatus] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const safeAreaStyle: React.CSSProperties = {
|
||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationKey: ['tenantAdminForgotPassword'],
|
||||
mutationFn: requestPasswordReset,
|
||||
onSuccess: (data) => {
|
||||
setError(null);
|
||||
setStatus(data.status ?? t('login.forgot.success', 'If your account exists, we will send a reset link.'));
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<YStack
|
||||
minHeight="100vh"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
paddingHorizontal="$4"
|
||||
paddingVertical="$5"
|
||||
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
|
||||
>
|
||||
<YStack width="100%" maxWidth={520} space="$4">
|
||||
<MobileCard backgroundColor="rgba(15, 23, 42, 0.6)" borderColor="rgba(255,255,255,0.12)">
|
||||
<YStack space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack
|
||||
width={42}
|
||||
height={42}
|
||||
borderRadius={12}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="rgba(255,255,255,0.12)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.15)"
|
||||
>
|
||||
<Mail size={18} color="white" />
|
||||
</XStack>
|
||||
<Text fontSize="$lg" fontWeight="800" color="white">
|
||||
{t('login.forgot.title', 'Forgot your password?')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color="rgba(255,255,255,0.7)">
|
||||
{t('login.forgot.copy', 'Enter your email address and we will send you a reset link.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard backgroundColor={surface} borderColor={border}>
|
||||
<YStack space="$3">
|
||||
<MobileField label={t('login.email', 'Email address')}>
|
||||
<MobileInput
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder={t('login.email_placeholder', 'name@example.com')}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</MobileField>
|
||||
|
||||
{status ? (
|
||||
<YStack borderRadius={12} padding="$2.5" borderWidth={1} borderColor={border} backgroundColor={accentSoft}>
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{status}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<YStack borderRadius={12} padding="$2.5" borderWidth={1} borderColor={dangerText} backgroundColor={dangerBg}>
|
||||
<Text fontSize="$sm" color={dangerText}>
|
||||
{error}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
onPress={() => mutation.mutate(email)}
|
||||
disabled={mutation.isPending || email.length === 0}
|
||||
height={52}
|
||||
borderRadius={16}
|
||||
backgroundColor={primary}
|
||||
borderColor="transparent"
|
||||
color="white"
|
||||
fontWeight="800"
|
||||
pressStyle={{ opacity: 0.9 }}
|
||||
style={{ boxShadow: '0 12px 24px rgba(255, 90, 95, 0.25)' }}
|
||||
>
|
||||
{mutation.isPending ? t('login.forgot.loading', 'Sending ...') : t('login.forgot.submit', 'Send reset link')}
|
||||
</Button>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<Button
|
||||
onPress={() => navigate(ADMIN_LOGIN_PATH)}
|
||||
backgroundColor="transparent"
|
||||
borderColor="rgba(255,255,255,0.5)"
|
||||
borderWidth={1}
|
||||
color="white"
|
||||
height={40}
|
||||
borderRadius={12}
|
||||
>
|
||||
{t('login.forgot.back', 'Back to login')}
|
||||
</Button>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Lock, Mail } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { adminPath, ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
||||
import {
|
||||
ADMIN_DEFAULT_AFTER_LOGIN_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_PUBLIC_FORGOT_PASSWORD_PATH,
|
||||
ADMIN_PUBLIC_HELP_PATH,
|
||||
} from '../constants';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { resolveReturnTarget } from '../lib/returnTo';
|
||||
import { useInstallPrompt } from './hooks/useInstallPrompt';
|
||||
import { getInstallBannerDismissed, setInstallBannerDismissed, shouldShowInstallBanner } from './lib/installBanner';
|
||||
import { MobileInstallBanner } from './components/MobileInstallBanner';
|
||||
import { ADMIN_GRADIENTS } from './theme';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { MobileCard } from './components/Primitives';
|
||||
import { MobileField, MobileInput } from './components/FormControls';
|
||||
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||
|
||||
type LoginResponse = {
|
||||
token: string;
|
||||
@@ -50,6 +60,7 @@ export default function MobileLoginPage() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const installPrompt = useInstallPrompt();
|
||||
const { text, muted, primary, dangerBg, dangerText, border, surface } = useAdminTheme();
|
||||
const safeAreaStyle: React.CSSProperties = {
|
||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||
@@ -124,76 +135,119 @@ export default function MobileLoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen items-center justify-center px-5 py-10 text-white"
|
||||
<YStack
|
||||
minHeight="100vh"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
paddingHorizontal="$4"
|
||||
paddingVertical="$5"
|
||||
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
|
||||
>
|
||||
<div className="w-full max-w-md space-y-8 rounded-3xl border border-white/10 bg-white/5 p-8 shadow-2xl shadow-blue-500/10 backdrop-blur-lg">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 ring-1 ring-white/15">
|
||||
<img src="/logo-transparent-md.png" alt={t('auth.logoAlt', 'Fotospiel')} className="h-10 w-10" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('login.panel_title', 'Team Login')}</h1>
|
||||
<p className="text-sm text-white/70">
|
||||
{t('login.panel_copy', 'Melde dich an, um Events, Fotos und Aufgaben zu verwalten.')}
|
||||
</p>
|
||||
</div>
|
||||
<YStack width="100%" maxWidth={520} space="$4">
|
||||
<MobileCard backgroundColor="rgba(15, 23, 42, 0.6)" borderColor="rgba(255,255,255,0.12)">
|
||||
<YStack alignItems="center" space="$3">
|
||||
<XStack
|
||||
width={56}
|
||||
height={56}
|
||||
borderRadius={16}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="rgba(255,255,255,0.12)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.15)"
|
||||
>
|
||||
<img src="/logo-transparent-md.png" alt={t('auth.logoAlt', 'Fotospiel')} width={40} height={40} />
|
||||
</XStack>
|
||||
<YStack alignItems="center" space="$1">
|
||||
<Text fontSize="$xl" fontWeight="800" color="white" textAlign="center">
|
||||
{t('login.panel_title', 'Fotospiel.App Event Login')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="rgba(255,255,255,0.7)" textAlign="center">
|
||||
{t('login.panel_copy', 'Melde dich an, um Events zu planen, Fotos zu moderieren und Aufgaben anzulegen.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-white/90" htmlFor="login-mobile">
|
||||
{t('login.username_or_email', 'E-Mail oder Benutzername')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-white/10 bg-white/10 px-3 py-3">
|
||||
<Mail size={16} className="text-white/70" />
|
||||
<input
|
||||
id="login-mobile"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
placeholder={t('login.username_or_email_placeholder', 'name@example.com')}
|
||||
className="w-full bg-transparent text-sm text-white placeholder:text-white/50 focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MobileCard backgroundColor={surface} borderColor={border}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<YStack space="$3">
|
||||
<MobileField
|
||||
label={t('login.email', 'E-Mail-Adresse')}
|
||||
hint={t('login.email_hint', 'Dein Benutzername ist deine E-Mail-Adresse.')}
|
||||
>
|
||||
<MobileInput
|
||||
id="login-mobile"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
placeholder={t('login.email_placeholder', 'name@example.com')}
|
||||
required
|
||||
/>
|
||||
</MobileField>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-white/90" htmlFor="password-mobile">
|
||||
{t('login.password', 'Passwort')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-white/10 bg-white/10 px-3 py-3">
|
||||
<Lock size={16} className="text-white/70" />
|
||||
<input
|
||||
<MobileField label={t('login.password', 'Passwort')}>
|
||||
<MobileInput
|
||||
id="password-mobile"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('login.password_placeholder', '••••••••')}
|
||||
className="w-full bg-transparent text-sm text-white placeholder:text-white/50 focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MobileField>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
onPress={() => navigate(ADMIN_PUBLIC_FORGOT_PASSWORD_PATH)}
|
||||
backgroundColor="transparent"
|
||||
borderColor="transparent"
|
||||
color={muted}
|
||||
height={32}
|
||||
alignSelf="flex-start"
|
||||
paddingHorizontal={0}
|
||||
>
|
||||
{t('login.forgot.link', 'Forgot your password?')}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-2xl text-sm font-semibold text-white shadow-lg shadow-rose-500/25 transition hover:brightness-110 disabled:opacity-70"
|
||||
style={{ background: ADMIN_GRADIENTS.primaryCta }}
|
||||
>
|
||||
<Loader2 className={`h-4 w-4 animate-spin ${isSubmitting ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span>{isSubmitting ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')}</span>
|
||||
</button>
|
||||
</form>
|
||||
{error ? (
|
||||
<YStack
|
||||
borderRadius={12}
|
||||
padding="$2.5"
|
||||
borderWidth={1}
|
||||
borderColor={dangerText}
|
||||
backgroundColor={dangerBg}
|
||||
>
|
||||
<Text fontSize="$sm" color={dangerText}>
|
||||
{error}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
height={52}
|
||||
borderRadius={16}
|
||||
backgroundColor={primary}
|
||||
borderColor="transparent"
|
||||
color="white"
|
||||
fontWeight="800"
|
||||
pressStyle={{ opacity: 0.9 }}
|
||||
style={{ boxShadow: '0 12px 24px rgba(255, 90, 95, 0.25)' }}
|
||||
>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Loader2 size={16} className={isSubmitting ? 'animate-spin' : ''} />
|
||||
<Text fontSize="$sm" color="white" fontWeight="800">
|
||||
{isSubmitting ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</YStack>
|
||||
</form>
|
||||
</MobileCard>
|
||||
|
||||
<MobileInstallBanner
|
||||
state={installBanner}
|
||||
@@ -205,20 +259,23 @@ export default function MobileLoginPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="text-center text-xs text-white/60">
|
||||
{t('login.support', 'Fragen? Schreib uns an support@fotospiel.de oder antworte direkt auf deine Einladung.')}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(adminPath('/faq'))}
|
||||
className="text-xs font-semibold text-white/70 underline underline-offset-4"
|
||||
<YStack alignItems="center" space="$2">
|
||||
<Text fontSize="$xs" color={muted} textAlign="center">
|
||||
{t('login.support', 'Fragen? Schreib uns an support@fotospiel.de oder antworte direkt auf deine Einladung.')}
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() => navigate(ADMIN_PUBLIC_HELP_PATH)}
|
||||
backgroundColor="transparent"
|
||||
borderColor="rgba(255,255,255,0.5)"
|
||||
borderWidth={1}
|
||||
color="white"
|
||||
height={40}
|
||||
borderRadius={12}
|
||||
>
|
||||
{t('login.faq', 'Hilfe & FAQ')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
167
resources/js/admin/mobile/PublicHelpPage.tsx
Normal file
167
resources/js/admin/mobile/PublicHelpPage.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpCircle, Mail, ShieldCheck } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { ADMIN_LOGIN_PATH } from '../constants';
|
||||
import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||
|
||||
type FaqItem = {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
export default function PublicHelpPage() {
|
||||
const { t } = useTranslation('auth');
|
||||
const navigate = useNavigate();
|
||||
const { text, muted, surface, border, accentSoft, primary } = useAdminTheme();
|
||||
const faqItems = t('login.help_faq_items', {
|
||||
returnObjects: true,
|
||||
defaultValue: [
|
||||
{
|
||||
question: 'I did not receive an invite.',
|
||||
answer: 'Please check spam and ask your event team to resend the invitation.',
|
||||
},
|
||||
{
|
||||
question: 'I cannot sign in.',
|
||||
answer: 'Sign-in works only with your email address. Your username equals your email.',
|
||||
},
|
||||
{
|
||||
question: 'My event is not visible.',
|
||||
answer: 'Make sure the event is published. Ask the owner to adjust the status.',
|
||||
},
|
||||
{
|
||||
question: 'Uploads are slow or stuck.',
|
||||
answer: 'Wait a moment and check the connection. If it persists, contact support.',
|
||||
},
|
||||
],
|
||||
}) as FaqItem[];
|
||||
const safeAreaStyle: React.CSSProperties = {
|
||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||
};
|
||||
|
||||
return (
|
||||
<YStack
|
||||
minHeight="100vh"
|
||||
paddingHorizontal="$4"
|
||||
paddingVertical="$4"
|
||||
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
|
||||
>
|
||||
<YStack width="100%" maxWidth={680} alignSelf="center" space="$4">
|
||||
<MobileCard backgroundColor="rgba(15, 23, 42, 0.55)" borderColor="rgba(255,255,255,0.08)">
|
||||
<YStack space="$3">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack
|
||||
width={42}
|
||||
height={42}
|
||||
borderRadius={12}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="rgba(255,255,255,0.12)"
|
||||
>
|
||||
<HelpCircle size={20} color="white" />
|
||||
</XStack>
|
||||
<Text fontSize="$lg" fontWeight="800" color="white">
|
||||
{t('login.help_title', 'Hilfe fuer Event-Admins')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color="rgba(255,255,255,0.7)">
|
||||
{t(
|
||||
'login.help_intro',
|
||||
'Hier findest du schnelle Antworten rund um Login, Zugriff und erste Schritte - auch ohne Anmeldung.',
|
||||
)}
|
||||
</Text>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard backgroundColor={surface} borderColor={border}>
|
||||
<YStack space="$3">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack
|
||||
width={36}
|
||||
height={36}
|
||||
borderRadius={12}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={accentSoft}
|
||||
>
|
||||
<ShieldCheck size={18} color={primary} />
|
||||
</XStack>
|
||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||
{t('login.help_faq_title', 'Haeufige Fragen vor dem Login')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<YStack space="$2">
|
||||
{faqItems.map((item, index) => (
|
||||
<YStack
|
||||
key={`${item.question}-${index}`}
|
||||
padding="$2.5"
|
||||
borderRadius={14}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor="rgba(255,255,255,0.6)"
|
||||
space="$1"
|
||||
>
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{item.question}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{item.answer}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard backgroundColor={surface} borderColor={border}>
|
||||
<YStack space="$3">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack
|
||||
width={36}
|
||||
height={36}
|
||||
borderRadius={12}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={accentSoft}
|
||||
>
|
||||
<Mail size={18} color={primary} />
|
||||
</XStack>
|
||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||
{t('login.help_support_title', 'Support kontaktieren')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('login.help_support_copy', 'Schreib uns bei Fragen an')}{' '}
|
||||
<Text fontWeight="700" color={text}>
|
||||
support@fotospiel.app
|
||||
</Text>
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={t('login.help_support_cta', 'E-Mail an Support senden')}
|
||||
onPress={() => {
|
||||
window.location.href = 'mailto:support@fotospiel.app';
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<Button
|
||||
onPress={() => navigate(ADMIN_LOGIN_PATH)}
|
||||
backgroundColor="transparent"
|
||||
borderColor="rgba(255,255,255,0.4)"
|
||||
borderWidth={1}
|
||||
color="white"
|
||||
height={44}
|
||||
borderRadius={14}
|
||||
>
|
||||
{t('login.help_back', 'Back to login')}
|
||||
</Button>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
221
resources/js/admin/mobile/ResetPasswordPage.tsx
Normal file
221
resources/js/admin/mobile/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { ADMIN_LOGIN_PATH } from '../constants';
|
||||
import { MobileCard } from './components/Primitives';
|
||||
import { MobileField, MobileInput } from './components/FormControls';
|
||||
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||
|
||||
type ResetResponse = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
type FieldErrors = Record<string, string[]>;
|
||||
|
||||
async function resetPassword(payload: {
|
||||
token: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
}): Promise<ResetResponse> {
|
||||
const response = await fetch('/api/v1/tenant-auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (response.status === 422) {
|
||||
const data = await response.json();
|
||||
const errors = (data.errors ?? {}) as FieldErrors;
|
||||
const flattened = Object.values(errors).flat();
|
||||
const message = flattened.join(' ') || 'Validation failed';
|
||||
const error = new Error(message) as Error & { errors?: FieldErrors };
|
||||
error.errors = errors;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Request failed.');
|
||||
}
|
||||
|
||||
return (await response.json()) as ResetResponse;
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { t } = useTranslation('auth');
|
||||
const navigate = useNavigate();
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const location = useLocation();
|
||||
const { text, muted, border, surface, accentSoft, primary, dangerBg, dangerText } = useAdminTheme();
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
const presetEmail = searchParams.get('email') ?? '';
|
||||
const [email, setEmail] = React.useState(presetEmail);
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [passwordConfirmation, setPasswordConfirmation] = React.useState('');
|
||||
const [status, setStatus] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = React.useState<FieldErrors>({});
|
||||
const safeAreaStyle: React.CSSProperties = {
|
||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationKey: ['tenantAdminResetPassword'],
|
||||
mutationFn: resetPassword,
|
||||
onSuccess: (data) => {
|
||||
setFieldErrors({});
|
||||
setError(null);
|
||||
setStatus(data.status ?? t('login.reset.success', 'Password updated.'));
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const typed = err as Error & { errors?: FieldErrors };
|
||||
setFieldErrors(typed.errors ?? {});
|
||||
setError(typed.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!token) {
|
||||
setError(t('login.reset.missing_token', 'Reset token missing.'));
|
||||
return;
|
||||
}
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
token,
|
||||
email,
|
||||
password,
|
||||
password_confirmation: passwordConfirmation,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<YStack
|
||||
minHeight="100vh"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
paddingHorizontal="$4"
|
||||
paddingVertical="$5"
|
||||
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
|
||||
>
|
||||
<YStack width="100%" maxWidth={520} space="$4">
|
||||
<MobileCard backgroundColor="rgba(15, 23, 42, 0.6)" borderColor="rgba(255,255,255,0.12)">
|
||||
<YStack space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack
|
||||
width={42}
|
||||
height={42}
|
||||
borderRadius={12}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="rgba(255,255,255,0.12)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255,255,255,0.15)"
|
||||
>
|
||||
<Lock size={18} color="white" />
|
||||
</XStack>
|
||||
<Text fontSize="$lg" fontWeight="800" color="white">
|
||||
{t('login.reset.title', 'Reset password')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color="rgba(255,255,255,0.7)">
|
||||
{t('login.reset.copy', 'Choose a new password for your event admin account.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard backgroundColor={surface} borderColor={border}>
|
||||
<YStack space="$3">
|
||||
<MobileField label={t('login.email', 'Email address')} error={fieldErrors.email?.[0]}>
|
||||
<MobileInput
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder={t('login.email_placeholder', 'name@example.com')}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</MobileField>
|
||||
|
||||
<MobileField label={t('login.reset.password', 'New password')} error={fieldErrors.password?.[0]}>
|
||||
<MobileInput
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder={t('login.reset.password_placeholder', '••••••••')}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</MobileField>
|
||||
|
||||
<MobileField
|
||||
label={t('login.reset.password_confirm', 'Confirm password')}
|
||||
error={fieldErrors.password_confirmation?.[0]}
|
||||
>
|
||||
<MobileInput
|
||||
type="password"
|
||||
value={passwordConfirmation}
|
||||
onChange={(event) => setPasswordConfirmation(event.target.value)}
|
||||
placeholder={t('login.reset.password_confirm_placeholder', '••••••••')}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</MobileField>
|
||||
|
||||
{status ? (
|
||||
<YStack borderRadius={12} padding="$2.5" borderWidth={1} borderColor={border} backgroundColor={accentSoft}>
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{status}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<YStack borderRadius={12} padding="$2.5" borderWidth={1} borderColor={dangerText} backgroundColor={dangerBg}>
|
||||
<Text fontSize="$sm" color={dangerText}>
|
||||
{error}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
onPress={handleSubmit}
|
||||
disabled={mutation.isPending || !token}
|
||||
height={52}
|
||||
borderRadius={16}
|
||||
backgroundColor={primary}
|
||||
borderColor="transparent"
|
||||
color="white"
|
||||
fontWeight="800"
|
||||
pressStyle={{ opacity: 0.9 }}
|
||||
style={{ boxShadow: '0 12px 24px rgba(255, 90, 95, 0.25)' }}
|
||||
>
|
||||
{mutation.isPending ? t('login.reset.loading', 'Saving ...') : t('login.reset.submit', 'Save new password')}
|
||||
</Button>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<Button
|
||||
onPress={() => navigate(ADMIN_LOGIN_PATH)}
|
||||
backgroundColor="transparent"
|
||||
borderColor="rgba(255,255,255,0.5)"
|
||||
borderWidth={1}
|
||||
color="white"
|
||||
height={40}
|
||||
borderRadius={12}
|
||||
>
|
||||
{t('login.reset.back', 'Back to login')}
|
||||
</Button>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,9 @@ const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage'));
|
||||
const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage'));
|
||||
const MobileDataExportsPage = React.lazy(() => import('./mobile/DataExportsPage'));
|
||||
const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage'));
|
||||
const MobilePublicHelpPage = React.lazy(() => import('./mobile/PublicHelpPage'));
|
||||
const MobileForgotPasswordPage = React.lazy(() => import('./mobile/ForgotPasswordPage'));
|
||||
const MobileResetPasswordPage = React.lazy(() => import('./mobile/ResetPasswordPage'));
|
||||
const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage'));
|
||||
const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage'));
|
||||
const MobileUploadsTabPage = React.lazy(() => import('./mobile/UploadsTabPage'));
|
||||
@@ -167,6 +170,9 @@ export const router = createBrowserRouter([
|
||||
{ index: true, element: <LandingGate /> },
|
||||
{ path: 'login', element: <MobileLoginPage /> },
|
||||
{ path: 'mobile/login', element: <MobileLoginPage /> },
|
||||
{ path: 'help', element: <MobilePublicHelpPage /> },
|
||||
{ path: 'forgot-password', element: <MobileForgotPasswordPage /> },
|
||||
{ path: 'reset-password/:token', element: <MobileResetPasswordPage /> },
|
||||
{ path: 'start', element: <LoginStartPage /> },
|
||||
{ path: 'logout', element: <LogoutPage /> },
|
||||
{ path: 'auth/callback', element: <AuthCallbackPage /> },
|
||||
|
||||
@@ -33,6 +33,7 @@ use App\Http\Controllers\Api\Tenant\TaskController;
|
||||
use App\Http\Controllers\Api\Tenant\TenantAdminTokenController;
|
||||
use App\Http\Controllers\Api\Tenant\TenantAnnouncementController;
|
||||
use App\Http\Controllers\Api\Tenant\TenantFeedbackController;
|
||||
use App\Http\Controllers\Api\TenantAuth\TenantAdminPasswordResetController;
|
||||
use App\Http\Controllers\Api\TenantBillingController;
|
||||
use App\Http\Controllers\Api\TenantPackageController;
|
||||
use App\Http\Controllers\RevenueCatWebhookController;
|
||||
@@ -68,6 +69,12 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::post('/login', [TenantAdminTokenController::class, 'store'])
|
||||
->middleware('throttle:tenant-auth')
|
||||
->name('login');
|
||||
Route::post('/forgot-password', [TenantAdminPasswordResetController::class, 'requestLink'])
|
||||
->middleware('throttle:tenant-auth')
|
||||
->name('forgot-password');
|
||||
Route::post('/reset-password', [TenantAdminPasswordResetController::class, 'reset'])
|
||||
->middleware('throttle:tenant-auth')
|
||||
->name('reset-password');
|
||||
|
||||
Route::middleware([EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class])->group(function () {
|
||||
Route::post('/exchange', [TenantAdminTokenController::class, 'exchange'])
|
||||
|
||||
97
tests/Feature/Auth/TenantAdminPasswordResetApiTest.php
Normal file
97
tests/Feature/Auth/TenantAdminPasswordResetApiTest.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ResetPasswordNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TenantAdminPasswordResetApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_forgot_password_sends_reset_link_for_tenant_admin(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'role' => 'tenant_admin',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson(route('api.v1.tenant-auth.forgot-password'), [
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
Notification::assertSentTo($user, ResetPasswordNotification::class);
|
||||
}
|
||||
|
||||
public function test_forgot_password_does_not_disclose_invalid_users(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'role' => 'user',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson(route('api.v1.tenant-auth.forgot-password'), [
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_reset_password_updates_tenant_admin_password(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'role' => 'tenant_admin',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$token = Password::broker()->createToken($user);
|
||||
|
||||
$response = $this->postJson(route('api.v1.tenant-auth.reset-password'), [
|
||||
'token' => $token,
|
||||
'email' => $user->email,
|
||||
'password' => 'NewPassword123!',
|
||||
'password_confirmation' => 'NewPassword123!',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$user->refresh();
|
||||
$this->assertTrue(Hash::check('NewPassword123!', $user->password));
|
||||
}
|
||||
|
||||
public function test_reset_password_blocks_non_admin_users(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'role' => 'user',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$token = Password::broker()->createToken($user);
|
||||
|
||||
$response = $this->postJson(route('api.v1.tenant-auth.reset-password'), [
|
||||
'token' => $token,
|
||||
'email' => $user->email,
|
||||
'password' => 'NewPassword123!',
|
||||
'password_confirmation' => 'NewPassword123!',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('email');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user