feat(auth): enhance error handling for token expiration and unauthorized access

This commit is contained in:
2026-03-14 03:00:30 +01:00
parent 791741fde0
commit c189056003
4 changed files with 41 additions and 23 deletions

View File

@@ -55,6 +55,10 @@ pub(super) fn check_access(
match mode { match mode {
AccessMode::Public => Ok(()), AccessMode::Public => Ok(()),
AccessMode::PasswordProtected => { AccessMode::PasswordProtected => {
// Owner always has access to their own channel without needing the password
if user.map(|u| u.id) == Some(owner_id) {
return Ok(());
}
let hash = password_hash.ok_or(ApiError::PasswordRequired)?; let hash = password_hash.ok_or(ApiError::PasswordRequired)?;
let supplied = supplied_password.unwrap_or("").trim(); let supplied = supplied_password.unwrap_or("").trim();
if supplied.is_empty() { if supplied.is_empty() {

View File

@@ -3,7 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Tv } from "lucide-react"; import { Tv } from "lucide-react";
import { api } from "@/lib/api"; import { api, ApiRequestError } from "@/lib/api";
import { useChannels } from "@/hooks/use-channels"; import { useChannels } from "@/hooks/use-channels";
import { useAuthContext } from "@/context/auth-context"; import { useAuthContext } from "@/context/auth-context";
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types"; import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
@@ -43,16 +43,17 @@ function slotLabel(slot: ScheduledSlotResponse) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function ChannelRow({ channel }: { channel: ChannelResponse }) { function ChannelRow({ channel }: { channel: ChannelResponse }) {
const { token } = useAuthContext(); const { token, isLoaded } = useAuthContext();
const { data: slots, isError, isPending } = useQuery({ const { data: slots, isError, error, isPending, isFetching } = useQuery({
queryKey: ["guide-epg", channel.id], queryKey: ["guide-epg", channel.id, token],
queryFn: () => { queryFn: () => {
const now = new Date(); const now = new Date();
const from = now.toISOString(); const from = now.toISOString();
const until = new Date(now.getTime() + 4 * 60 * 60 * 1000).toISOString(); const until = new Date(now.getTime() + 4 * 60 * 60 * 1000).toISOString();
return api.schedule.getEpg(channel.id, token ?? "", from, until); return api.schedule.getEpg(channel.id, token ?? "", from, until);
}, },
enabled: isLoaded,
refetchInterval: 30_000, refetchInterval: 30_000,
retry: false, retry: false,
}); });
@@ -117,8 +118,10 @@ function ChannelRow({ channel }: { channel: ChannelResponse }) {
/> />
</div> </div>
</div> </div>
) : isPending ? ( ) : isPending && isFetching ? (
<p className="text-xs italic text-zinc-600">Loading</p> <p className="text-xs italic text-zinc-600">Loading</p>
) : isError && error instanceof ApiRequestError && error.status === 401 ? (
<p className="text-xs italic text-zinc-600">Sign in to view this channel</p>
) : ( ) : (
<p className="text-xs italic text-zinc-600"> <p className="text-xs italic text-zinc-600">
{isError || !slots?.length {isError || !slots?.length

View File

@@ -54,7 +54,7 @@
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); --primary: oklch(0.702 0.176 48.5);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
@@ -65,7 +65,7 @@
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.702 0.176 48.5);
--chart-1: oklch(0.809 0.105 251.813); --chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815); --chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881); --chart-3: oklch(0.546 0.245 262.881);
@@ -74,12 +74,12 @@
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.702 0.176 48.5);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.702 0.176 48.5);
} }
.dark { .dark {
@@ -89,8 +89,8 @@
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.702 0.176 48.5);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
@@ -100,7 +100,7 @@
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.702 0.176 48.5);
--chart-1: oklch(0.809 0.105 251.813); --chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815); --chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881); --chart-3: oklch(0.546 0.245 262.881);
@@ -108,12 +108,12 @@
--chart-5: oklch(0.424 0.199 265.638); --chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.702 0.176 48.5);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.702 0.176 48.5);
} }
@layer base { @layer base {

View File

@@ -14,19 +14,30 @@ import { Toaster } from "@/components/ui/sonner";
import { ApiRequestError } from "@/lib/api"; import { ApiRequestError } from "@/lib/api";
function QueryProvider({ children }: { children: React.ReactNode }) { function QueryProvider({ children }: { children: React.ReactNode }) {
const { setToken } = useAuthContext(); const { token, setToken } = useAuthContext();
const router = useRouter(); const router = useRouter();
const [queryClient] = useState(() => { const [queryClient] = useState(() => {
const on401 = (error: unknown) => { return new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
// Only redirect on 401 if the user had a token (expired session).
// Guests hitting 401 on restricted content should not be redirected.
if (error instanceof ApiRequestError && error.status === 401 && token) {
setToken(null);
router.push("/login");
}
},
}),
mutationCache: new MutationCache({
onError: (error) => {
// Mutations always require auth — redirect on 401 regardless.
if (error instanceof ApiRequestError && error.status === 401) { if (error instanceof ApiRequestError && error.status === 401) {
setToken(null); setToken(null);
router.push("/login"); router.push("/login");
} }
}; },
return new QueryClient({ }),
queryCache: new QueryCache({ onError: on401 }),
mutationCache: new MutationCache({ onError: on401 }),
defaultOptions: { queries: { staleTime: 60 * 1000 } }, defaultOptions: { queries: { staleTime: 60 * 1000 } },
}); });
}); });