A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(auth): prevent unauthorized users from seeing the dashboard

Trezy 55c9a305 2dfe4828

+36 -5
+23
src/auth/routes.rs
··· 210 210 .await 211 211 .ok_or_else(|| AppError::Internal("no DID in OAuth session".into()))?; 212 212 213 + // Check if the user is authorized to access the dashboard. 214 + // Allow login when no users exist yet (first user will be bootstrapped as admin). 215 + // Otherwise, only allow users already in the users table. 216 + let user_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") 217 + .fetch_one(&state.db) 218 + .await 219 + .map_err(|e| AppError::Internal(format!("user count query failed: {e}")))?; 220 + 221 + if user_count.0 > 0 { 222 + let user_exists: Option<(i32,)> = sqlx::query_as(&adapt_sql( 223 + "SELECT 1 FROM users WHERE did = ?", 224 + state.db_backend, 225 + )) 226 + .bind(did.as_ref()) 227 + .fetch_optional(&state.db) 228 + .await 229 + .map_err(|e| AppError::Internal(format!("user lookup failed: {e}")))?; 230 + 231 + if user_exists.is_none() { 232 + return Ok((jar, Redirect::to("/login?error=not_authorized"))); 233 + } 234 + } 235 + 213 236 // Look up the client_key for the API client so we can store it in the session cookie 214 237 // for per-client rate limiting. 215 238 let client_key = if let Some(ref cid) = client_id {
+9 -2
web/src/app/login/page.tsx
··· 1 1 "use client" 2 2 3 3 import { useEffect } from "react" 4 - import { useRouter } from "next/navigation" 4 + import { useRouter, useSearchParams } from "next/navigation" 5 5 import { LoginForm } from "@/components/login-form" 6 6 import { useAuth } from "@/lib/auth-context" 7 + 8 + const ERROR_MESSAGES: Record<string, string> = { 9 + not_authorized: "Your account is not authorized to access this dashboard.", 10 + } 7 11 8 12 export default function LoginPage() { 9 13 const { did } = useAuth() 10 14 const router = useRouter() 15 + const searchParams = useSearchParams() 16 + const errorParam = searchParams.get("error") 17 + const errorMessage = errorParam ? ERROR_MESSAGES[errorParam] ?? errorParam : null 11 18 12 19 useEffect(() => { 13 20 if (did) router.replace("/dashboard") ··· 18 25 return ( 19 26 <div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> 20 27 <div className="w-full max-w-sm"> 21 - <LoginForm /> 28 + <LoginForm externalError={errorMessage} /> 22 29 </div> 23 30 </div> 24 31 )
+4 -3
web/src/components/login-form.tsx
··· 15 15 16 16 export function LoginForm({ 17 17 className, 18 + externalError, 18 19 ...props 19 - }: React.ComponentProps<"div">) { 20 + }: React.ComponentProps<"div"> & { externalError?: string | null }) { 20 21 const [handle, setHandle] = useState("") 21 22 const [loading, setLoading] = useState(false) 22 23 const [error, setError] = useState<string | null>(null) ··· 45 46 Sign in with your ATProto account to manage your AppView. 46 47 </FieldDescription> 47 48 </div> 48 - {error && ( 49 - <p className="text-destructive text-center text-sm">{error}</p> 49 + {(externalError || error) && ( 50 + <p className="text-destructive text-center text-sm">{externalError || error}</p> 50 51 )} 51 52 <Field> 52 53 <FieldLabel htmlFor="handle">Handle</FieldLabel>