feat(invites): role-granting + usage-capped invites; audit hardening (P7)
Invites - migration 029: space_invites.role (member_role enum, default 'member' — legacy invites and the spaces.invite_code path keep granting member). schema.sql + types/database.ts mirrored - lib/invite-logic.ts (pure, tested): canAssignInviteRole — admin may grant any role; board may grant anything except admin; nobody else. Privilege-escalation guard on top of the existing admin/board gate - createInvite/updateInvite take + enforce role via that guard; createInviteSchema/updateInviteSchema validate the enum - joinSpace applies the invite's granted role (validated), default member; uses_count/max_uses/expiry already enforced - invites panel: role picker (filtered to what the creator may grant), one-click "single use", per-invite role badge - space-scoped invite link /join/<space>?code=… via a presentational app/join/[space] landing that hands off to the proven /signup?invite= flow; /join added to middleware PUBLIC_ROUTES Audit hardening (from this pass's review) - /login now honors a validated same-origin ?next= (was hardcoded to /dashboard) so the public_auth form sign-in round-trips; OAuth threads next through the callback (which already honored it) - submitForm requires formId; submitFormSchema drops the now-ambiguous slug path (form slugs are per-space after 028). Both callers already pass formId. Tests updated 337 tests; build verified (Compiled successfully, exit 0). No deploy.
This commit is contained in:
+15
-2
@@ -13,6 +13,19 @@ export default function LoginPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [oauthLoading, setOauthLoading] = useState<'github' | 'google' | null>(null)
|
||||
|
||||
// Honor ?next= so a signed-out visitor to a public_auth form (or any gated
|
||||
// link) returns where they were headed. Only same-origin absolute paths are
|
||||
// allowed — never an external URL or protocol-relative // — to avoid an
|
||||
// open-redirect.
|
||||
function safeNext(): string {
|
||||
if (typeof window === 'undefined') return '/dashboard'
|
||||
const raw = new URLSearchParams(window.location.search).get('next')
|
||||
if (raw && raw.startsWith('/') && !raw.startsWith('//') && !raw.startsWith('/\\')) {
|
||||
return raw
|
||||
}
|
||||
return '/dashboard'
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
@@ -23,7 +36,7 @@ export default function LoginPage() {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
} else {
|
||||
router.push('/dashboard')
|
||||
router.push(safeNext())
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
@@ -35,7 +48,7 @@ export default function LoginPage() {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
redirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(safeNext())}`,
|
||||
},
|
||||
})
|
||||
if (error) {
|
||||
|
||||
Reference in New Issue
Block a user