session state simplification

This commit is contained in:
Michael Freno
2026-01-12 09:24:58 -05:00
parent ed16b277f7
commit f68f1f462a
32 changed files with 132 additions and 381 deletions

View File

@@ -7,14 +7,14 @@ const DeletePostButton = lazy(() => import("./DeletePostButton"));
export interface CardProps {
post: PostCardData;
privilegeLevel: "anonymous" | "admin" | "user";
isAdmin: boolean;
index?: number;
}
export default function Card(props: CardProps) {
return (
<div class="bg-base border-text relative z-0 mx-auto h-96 w-full overflow-hidden rounded-lg border shadow-lg lg:w-5/6 xl:w-3/4">
<Show when={props.privilegeLevel === "admin"}>
<Show when={props.isAdmin}>
<div class="border-opacity-20 bg-opacity-40 border-text bg-text absolute top-0 w-full border-b px-2 py-4 backdrop-blur-md md:px-6">
<div class="flex justify-between">
<Show when={!props.post.published}>
@@ -60,7 +60,7 @@ export default function Card(props: CardProps) {
</div>
<CardLinks
postTitle={props.post.title}
privilegeLevel={props.privilegeLevel}
isAdmin={props.isAdmin}
postID={props.post.id}
/>
</div>

View File

@@ -5,7 +5,7 @@ import { Spinner } from "~/components/Spinner";
export interface CardLinksProps {
postTitle: string;
postID: number;
privilegeLevel: string;
isAdmin: boolean;
}
export default function CardLinks(props: CardLinksProps) {
@@ -25,7 +25,7 @@ export default function CardLinks(props: CardLinksProps) {
<Spinner size={24} />
</Show>
</A>
<Show when={props.privilegeLevel === "admin"}>
<Show when={props.isAdmin}>
<A
href={`/blog/edit/${props.postID}`}
onClick={() => setEditLoading(true)}

View File

@@ -122,13 +122,10 @@ export default function CommentBlock(props: CommentBlockProps) {
);
const canDelete = () =>
props.currentUserID === props.comment.commenter_id ||
props.privilegeLevel === "admin";
props.currentUserID === props.comment.commenter_id || props.isAdmin;
const canEdit = () => props.currentUserID === props.comment.commenter_id;
const isAnonymous = () => props.privilegeLevel === "anonymous";
const replyIconColor = () => "var(--color-peach)";
return (
@@ -163,12 +160,12 @@ export default function CommentBlock(props: CommentBlockProps) {
hasUpvoted()
? "fill-green"
: `fill-text hover:fill-green ${
isAnonymous() ? "tooltip z-50" : ""
!props.isAuthenticated ? "tooltip z-50" : ""
}`
}`}
>
<ThumbsUpEmoji />
<Show when={isAnonymous()}>
<Show when={!props.isAuthenticated}>
<div class="tooltip-text -ml-16 w-32 text-white">
You must be logged in
</div>
@@ -190,14 +187,14 @@ export default function CommentBlock(props: CommentBlockProps) {
hasDownvoted()
? "fill-red"
: `fill-text hover:fill-red ${
isAnonymous() ? "tooltip z-50" : ""
!props.isAuthenticated ? "tooltip z-50" : ""
}`
}`}
>
<div class="rotate-180">
<ThumbsUpEmoji />
</div>
<Show when={isAnonymous()}>
<Show when={!props.isAuthenticated}>
<div class="tooltip-text -ml-16 w-32">
You must be logged in
</div>
@@ -309,7 +306,7 @@ export default function CommentBlock(props: CommentBlockProps) {
currentUserID={props.currentUserID}
reactions={reactions()}
showingReactionOptions={showingReactionOptions()}
privilegeLevel={props.privilegeLevel}
isAuthenticated={props.isAuthenticated}
commentReaction={props.commentReaction}
/>
</div>
@@ -325,7 +322,7 @@ export default function CommentBlock(props: CommentBlockProps) {
>
<CommentInputBlock
isReply={true}
privilegeLevel={props.privilegeLevel}
isAuthenticated={props.isAuthenticated}
parent_id={props.comment.id}
post_id={props.projectID}
currentUserID={props.currentUserID}
@@ -348,7 +345,8 @@ export default function CommentBlock(props: CommentBlockProps) {
child_comments={props.allComments?.filter(
(comment) => comment.parent_comment_id === childComment.id
)}
privilegeLevel={props.privilegeLevel}
isAuthenticated={props.isAuthenticated}
isAdmin={props.isAdmin}
currentUserID={props.currentUserID}
reactionMap={props.reactionMap}
level={props.level + 1}

View File

@@ -84,13 +84,11 @@ export default function CommentDeletionPrompt(
onChange={handleNormalDeleteCheckbox}
/>
<div class="my-auto px-2 text-sm font-normal">
{props.privilegeLevel === "admin"
? "Confirm User Delete?"
: "Confirm Delete?"}
{props.isAdmin ? "Confirm User Delete?" : "Confirm Delete?"}
</div>
</div>
</div>
<Show when={props.privilegeLevel === "admin"}>
<Show when={props.isAdmin}>
<div class="flex w-full justify-center">
<div class="flex pt-4">
<input

View File

@@ -18,7 +18,7 @@ export default function CommentInputBlock(props: CommentInputBlockProps) {
}
};
if (props.privilegeLevel === "user" || props.privilegeLevel === "admin") {
if (props.isAuthenticated) {
return (
<div class="flex w-full justify-center select-none">
<div class="h-fit w-3/4">

View File

@@ -6,7 +6,6 @@ import type {
UserPublicData,
ReactionType,
ModificationType,
PrivilegeLevel,
SortingMode
} from "~/types/comment";
import CommentInputBlock from "./CommentInputBlock";
@@ -20,28 +19,6 @@ const COMMENT_SORTING_OPTIONS: { val: SortingMode }[] = [
{ val: "hot" }
];
interface CommentSectionProps {
privilegeLevel: PrivilegeLevel;
allComments: Comment[];
topLevelComments: Comment[];
postID: number;
reactionMap: Map<number, CommentReaction[]>;
currentUserID: string;
userCommentMap: Map<UserPublicData, number[]> | undefined;
newComment: (commentBody: string, parentCommentID?: number) => Promise<void>;
commentSubmitLoading: boolean;
toggleModification: (
commentID: number,
commenterID: string,
commentBody: string,
modificationType: ModificationType,
commenterImage?: string,
commenterEmail?: string,
commenterDisplayName?: string
) => void;
commentReaction: (reactionType: ReactionType, commentID: number) => void;
}
export default function CommentSection(props: CommentSectionProps) {
const [searchParams] = useSearchParams();
@@ -66,7 +43,7 @@ export default function CommentSection(props: CommentSectionProps) {
<div class="mb-1">
<CommentInputBlock
isReply={false}
privilegeLevel={props.privilegeLevel}
isAuthenticated={props.isAuthenticated}
post_id={props.postID}
socket={undefined}
currentUserID={props.currentUserID}
@@ -90,7 +67,8 @@ export default function CommentSection(props: CommentSectionProps) {
<div id="comments">
<CommentSorting
topLevelComments={props.topLevelComments}
privilegeLevel={props.privilegeLevel}
isAuthenticated={props.isAuthenticated}
isAdmin={props.isAdmin}
postID={props.postID}
allComments={props.allComments}
reactionMap={props.reactionMap}

View File

@@ -855,7 +855,8 @@ export default function CommentSectionWrapper(
</Show>
<CommentSection
privilegeLevel={props.privilegeLevel}
isAuthenticated={props.isAuthenticated}
isAdmin={props.isAdmin}
allComments={allComments()}
topLevelComments={topLevelComments()}
postID={props.id}
@@ -875,7 +876,7 @@ export default function CommentSectionWrapper(
commenterImage={commenterImageForModification()}
commenterEmail={commenterEmailForModification()}
commenterDisplayName={commenterDisplayNameForModification()}
privilegeLevel={props.privilegeLevel}
isAdmin={props.isAdmin}
commentDeletionLoading={commentDeletionLoading()}
deleteComment={deleteComment}
onClose={() => {

View File

@@ -51,7 +51,8 @@ export default function CommentSorting(props: CommentSortingProps) {
child_comments={props.allComments?.filter(
(comment) => comment.parent_comment_id === topLevelComment.id
)}
privilegeLevel={props.privilegeLevel}
isAuthenticated={props.isAuthenticated}
isAdmin={props.isAdmin}
currentUserID={props.currentUserID}
reactionMap={props.reactionMap}
level={0}

View File

@@ -10,7 +10,7 @@ export interface Tag {
export interface PostSortingProps {
posts: PostCardData[];
tags: Tag[];
privilegeLevel: "anonymous" | "admin" | "user";
isAdmin: boolean;
filters?: string;
sort?: string;
include?: string;
@@ -36,7 +36,7 @@ export default function PostSorting(props: PostSortingProps) {
const filteredPosts = createMemo(() => {
let filtered = props.posts;
if (props.privilegeLevel === "admin" && props.status) {
if (props.isAdmin && props.status) {
if (props.status === "published") {
filtered = filtered.filter((post) => post.published === 1);
} else if (props.status === "unpublished") {
@@ -141,11 +141,7 @@ export default function PostSorting(props: PostSortingProps) {
<For each={sortedPosts()}>
{(post, index) => (
<div class="my-4">
<Card
post={post}
privilegeLevel={props.privilegeLevel}
index={index()}
/>
<Card post={post} isAdmin={props.isAdmin} index={index()} />
</div>
)}
</For>

View File

@@ -10,7 +10,7 @@ export interface PostLike {
export interface SessionDependantLikeProps {
currentUserID: string | undefined | null;
privilegeLevel: "admin" | "user" | "anonymous";
isAuthenticated: boolean;
likes: PostLike[];
projectID: number;
}
@@ -61,7 +61,7 @@ export default function SessionDependantLike(props: SessionDependantLikeProps) {
return (
<Show
when={props.privilegeLevel !== "anonymous"}
when={props.isAuthenticated}
fallback={
<button class="tooltip flex flex-col">
<div class="mx-auto">

View File

@@ -65,7 +65,7 @@ export const AuthProvider: ParentComponent = (props) => {
const email = () => serverAuth()?.email ?? null;
const displayName = () => serverAuth()?.displayName ?? null;
const userId = () => serverAuth()?.userId ?? null;
const isAdmin = () => serverAuth()?.privilegeLevel === "admin";
const isAdmin = () => serverAuth()?.isAdmin ?? false;
const isEmailVerified = () => serverAuth()?.emailVerified ?? false;
// Server handles all token refresh logic

View File

@@ -9,6 +9,7 @@ export const model: { [key: string]: string } = {
display_name TEXT,
provider TEXT,
image TEXT,
is_admin INTEGER DEFAULT 0,
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
failed_attempts INTEGER DEFAULT 0,
locked_until TEXT

4
src/env/server.ts vendored
View File

@@ -2,8 +2,6 @@ import { z } from "zod";
const serverEnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
ADMIN_EMAIL: z.string().min(1),
ADMIN_ID: z.string().min(1),
JWT_SECRET_KEY: z.string().min(1),
AWS_REGION: z.string().min(1),
AWS_S3_BUCKET_NAME: z.string().min(1),
@@ -109,8 +107,6 @@ export const isMissingEnvVar = (varName: string): boolean => {
export const getMissingEnvVars = (): string[] => {
const requiredServerVars = [
"NODE_ENV",
"ADMIN_EMAIL",
"ADMIN_ID",
"JWT_SECRET_KEY",
"AWS_REGION",
"AWS_S3_BUCKET_NAME",

View File

@@ -7,7 +7,7 @@ export interface UserState {
email: string | null;
displayName: string | null;
emailVerified: boolean;
privilegeLevel: "admin" | "user" | "anonymous";
isAdmin: boolean;
}
/**
@@ -29,7 +29,7 @@ export const getUserState = query(async (): Promise<UserState> => {
email: null,
displayName: null,
emailVerified: false,
privilegeLevel: "anonymous"
isAdmin: false
};
}
@@ -42,7 +42,7 @@ export const getUserState = query(async (): Promise<UserState> => {
email: null,
displayName: null,
emailVerified: false,
privilegeLevel: "anonymous"
isAdmin: false
};
}
@@ -59,7 +59,7 @@ export const getUserState = query(async (): Promise<UserState> => {
email: null,
displayName: null,
emailVerified: false,
privilegeLevel: "anonymous"
isAdmin: false
};
}
@@ -71,7 +71,7 @@ export const getUserState = query(async (): Promise<UserState> => {
email: user.email ?? null,
displayName: user.display_name ?? null,
emailVerified: user.email_verified === 1,
privilegeLevel: auth.isAdmin ? "admin" : "user"
isAdmin: auth.isAdmin
};
}, "user-auth-state");

View File

@@ -50,15 +50,14 @@ export function isValidCommentBody(body: string): boolean {
export function canModifyComment(
userID: string,
commenterID: string,
privilegeLevel: "admin" | "user" | "anonymous"
isAuthenticated: boolean,
isAdmin: boolean
): boolean {
if (privilegeLevel === "admin") return true;
if (privilegeLevel === "anonymous") return false;
if (isAdmin) return true;
if (!isAuthenticated) return false;
return userID === commenterID;
}
export function canDatabaseDelete(
privilegeLevel: "admin" | "user" | "anonymous"
): boolean {
return privilegeLevel === "admin";
export function canDatabaseDelete(isAdmin: boolean): boolean {
return isAdmin;
}

View File

@@ -8,7 +8,7 @@ const checkAdmin = query(async (): Promise<boolean> => {
const { getUserState } = await import("~/lib/auth-query");
const userState = await getUserState();
if (userState.privilegeLevel !== "admin") {
if (!userState.isAdmin) {
console.log("redirect");
throw redirect("/");
}

View File

@@ -34,7 +34,8 @@ const getPostByTitle = query(
const { getFeatureFlags } = await import("~/server/feature-flags");
const event = getRequestEvent()!;
const userState = await getUserState();
const privilegeLevel = userState.privilegeLevel;
const isAuthenticated = userState.isAuthenticated;
const isAdmin = userState.isAdmin;
const userID = userState.userId;
const conn = ConnectionFactory();
@@ -51,7 +52,8 @@ const getPostByTitle = query(
tags: [],
userCommentArray: [],
reactionArray: [],
privilegeLevel: "anonymous" as const,
isAuthenticated: false,
isAdmin: false,
userID: null
};
}
@@ -77,13 +79,14 @@ const getPostByTitle = query(
tags: [],
userCommentArray: [],
reactionArray: [],
privilegeLevel: "anonymous" as const,
isAuthenticated: false,
isAdmin: false,
userID: null
};
}
let query = "SELECT * FROM Post WHERE title = ?";
if (privilegeLevel !== "admin") {
if (!isAdmin) {
query += ` AND published = TRUE`;
}
@@ -110,7 +113,8 @@ const getPostByTitle = query(
tags: [],
userCommentArray: [],
reactionArray: [],
privilegeLevel: "anonymous" as const,
isAuthenticated: false,
isAdmin: false,
userID: null
};
}
@@ -123,14 +127,15 @@ const getPostByTitle = query(
tags: [],
userCommentArray: [],
reactionArray: [],
privilegeLevel: "anonymous" as const,
isAuthenticated: false,
isAdmin: false,
userID: null
};
}
const conditionalContext = {
isAuthenticated: userID !== null,
privilegeLevel: privilegeLevel,
isAdmin: isAdmin,
userId: userID,
currentDate: new Date(),
featureFlags: getFeatureFlags(),
@@ -245,7 +250,8 @@ const getPostByTitle = query(
topLevelComments,
userCommentArray,
reactionArray,
privilegeLevel,
isAuthenticated,
isAdmin,
userID,
sortBy,
reads: post.reads || 0
@@ -429,7 +435,7 @@ export default function PostPage() {
<div>
<SessionDependantLike
currentUserID={postData.userID}
privilegeLevel={postData.privilegeLevel}
isAuthenticated={postData.isAuthenticated}
likes={postData.likes as any[]}
projectID={p().id}
/>
@@ -455,7 +461,8 @@ export default function PostPage() {
class="mx-4 pt-12 pb-12 md:mx-8 lg:mx-12"
>
<CommentSectionWrapper
privilegeLevel={postData.privilegeLevel}
isAuthenticated={postData.isAuthenticated}
isAdmin={postData.isAdmin}
allComments={postData.comments as Comment[]}
topLevelComments={
postData.topLevelComments as Comment[]

View File

@@ -13,7 +13,7 @@ const checkAdminAccess = query(async () => {
// Reuse shared auth query for consistency
const userState = await getUserState();
if (userState.privilegeLevel !== "admin") {
if (!userState.isAdmin) {
throw redirect("/401");
}

View File

@@ -12,7 +12,7 @@ const getPostForEdit = query(async (id: string) => {
const { ConnectionFactory } = await import("~/server/utils");
const userState = await getUserState();
if (userState.privilegeLevel !== "admin") {
if (!userState.isAdmin) {
throw redirect("/401");
}
@@ -35,7 +35,7 @@ const getPostForEdit = query(async (id: string) => {
return {
post,
tags,
privilegeLevel: userState.privilegeLevel,
isAdmin: userState.isAdmin,
userID: userState.userId
};
}, "post-for-edit");

View File

@@ -15,10 +15,10 @@ const getPosts = query(async () => {
const { ConnectionFactory } = await import("~/server/utils");
const { withCacheAndStale } = await import("~/server/cache");
const userState = await getUserState();
const privilegeLevel = userState.privilegeLevel;
const isAdmin = userState.isAdmin;
return withCacheAndStale(
`posts-${privilegeLevel}`,
`posts-${isAdmin ? "admin" : "user"}`,
CACHE_CONFIG.BLOG_POSTS_LIST_CACHE_TTL_MS,
async () => {
const conn = ConnectionFactory();
@@ -43,7 +43,7 @@ const getPosts = query(async () => {
LEFT JOIN Comment c ON p.id = c.post_id
`;
if (privilegeLevel !== "admin") {
if (!isAdmin) {
postsQuery += ` WHERE p.published = TRUE`;
}
@@ -57,7 +57,7 @@ const getPosts = query(async () => {
SELECT t.value, t.post_id
FROM Tag t
JOIN Post p ON t.post_id = p.id
${privilegeLevel !== "admin" ? "WHERE p.published = TRUE" : ""}
${!isAdmin ? "WHERE p.published = TRUE" : ""}
ORDER BY t.value ASC
`;
@@ -70,7 +70,7 @@ const getPosts = query(async () => {
tagMap[key] = (tagMap[key] || 0) + 1;
});
return { posts, tags, tagMap, privilegeLevel };
return { posts, tags, tagMap, isAdmin };
}
);
}, "posts");
@@ -106,11 +106,11 @@ export default function BlogIndex() {
<TagSelector tagMap={loadedData().tagMap} />
</Show>
<Show when={loadedData().privilegeLevel === "admin"}>
<Show when={loadedData().isAdmin}>
<PublishStatusToggle />
</Show>
<Show when={loadedData().privilegeLevel === "admin"}>
<Show when={loadedData().isAdmin}>
<div class="mt-2 flex justify-center md:mt-0 md:justify-end">
<A
href="/blog/create"
@@ -130,7 +130,7 @@ export default function BlogIndex() {
<PostSorting
posts={loadedData().posts}
tags={loadedData().tags}
privilegeLevel={loadedData().privilegeLevel}
isAdmin={loadedData().isAdmin}
filters={filters()}
sort={sort()}
include={include()}

View File

@@ -7,7 +7,7 @@ import { getUserState } from "~/lib/auth-query";
const checkAdminAccess = query(async () => {
"use server";
const userState = await getUserState();
return { privilegeLevel: userState.privilegeLevel };
return { isAdmin: userState.isAdmin };
}, "test-auth-state");
type EndpointTest = {
@@ -916,7 +916,7 @@ export default function TestPage() {
description="tRPC API testing dashboard for developers to test endpoints and verify functionality."
/>
<Show
when={authState()?.privilegeLevel === "admin"}
when={authState()?.isAdmin}
fallback={
<div class="w-full pt-[30vh] text-center">
<div class="text-text text-2xl">Unauthorized</div>

View File

@@ -306,14 +306,11 @@ export const authRouter = createTRPCRouter({
}
}
const isAdmin = userId === env.ADMIN_ID;
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));
await createAuthSession(
getH3Event(ctx),
userId,
isAdmin,
true, // OAuth defaults to remember
clientIP,
userAgent
@@ -521,15 +518,12 @@ export const authRouter = createTRPCRouter({
}
}
const isAdmin = userId === env.ADMIN_ID;
// Create session with Vinxi (OAuth defaults to remember me)
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));
await createAuthSession(
getH3Event(ctx),
userId,
isAdmin,
true, // OAuth defaults to remember
clientIP,
userAgent
@@ -647,7 +641,6 @@ export const authRouter = createTRPCRouter({
}
const userId = (res.rows[0] as unknown as User).id;
const isAdmin = userId === env.ADMIN_ID;
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));
@@ -655,7 +648,6 @@ export const authRouter = createTRPCRouter({
await createAuthSession(
getH3Event(ctx),
userId,
isAdmin,
rememberMe,
clientIP,
userAgent
@@ -780,7 +772,6 @@ export const authRouter = createTRPCRouter({
}
const userId = (res.rows[0] as unknown as User).id;
const isAdmin = userId === env.ADMIN_ID;
// Use rememberMe from JWT if not provided in input, default to false
const shouldRemember =
@@ -791,7 +782,6 @@ export const authRouter = createTRPCRouter({
await createAuthSession(
getH3Event(ctx),
userId,
isAdmin,
shouldRemember,
clientIP,
userAgent
@@ -983,12 +973,10 @@ export const authRouter = createTRPCRouter({
// Create session with client info
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));
const isAdmin = userId === env.ADMIN_ID;
await createAuthSession(
getH3Event(ctx),
userId,
isAdmin,
true, // Always use persistent sessions
clientIP,
userAgent
@@ -1150,14 +1138,11 @@ export const authRouter = createTRPCRouter({
// Reset rate limits on successful login
await resetLoginRateLimits(email, clientIP);
const isAdmin = user.id === env.ADMIN_ID;
// Create session with Vinxi
const userAgent = getUserAgent(getH3Event(ctx));
await createAuthSession(
getH3Event(ctx),
user.id,
isAdmin,
rememberMe ?? false, // Default to session cookie (expires on browser close)
clientIP,
userAgent

View File

@@ -7,9 +7,9 @@ import { CACHE_CONFIG } from "~/config";
const BLOG_CACHE_TTL = CACHE_CONFIG.BLOG_CACHE_TTL_MS;
const getAllPostsData = async (privilegeLevel: string) => {
const getAllPostsData = async (isAdmin: boolean) => {
return withCacheAndStale(
`blog-posts-${privilegeLevel}`,
`blog-posts-${isAdmin ? "admin" : "public"}`,
BLOG_CACHE_TTL,
async () => {
const conn = ConnectionFactory();
@@ -34,7 +34,7 @@ const getAllPostsData = async (privilegeLevel: string) => {
LEFT JOIN Comment c ON p.id = c.post_id
`;
if (privilegeLevel !== "admin") {
if (!isAdmin) {
postsQuery += ` WHERE p.published = TRUE`;
}
@@ -48,7 +48,7 @@ const getAllPostsData = async (privilegeLevel: string) => {
SELECT t.value, t.post_id
FROM Tag t
JOIN Post p ON t.post_id = p.id
${privilegeLevel !== "admin" ? "WHERE p.published = TRUE" : ""}
${!isAdmin ? "WHERE p.published = TRUE" : ""}
ORDER BY t.value ASC
`;
@@ -64,21 +64,21 @@ const getAllPostsData = async (privilegeLevel: string) => {
tagMap[key] = (tagMap[key] || 0) + 1;
});
return { posts, tags, tagMap, privilegeLevel };
return { posts, tags, tagMap, isAdmin };
}
);
};
export const blogRouter = createTRPCRouter({
getRecentPosts: publicProcedure.query(async ({ ctx }) => {
const allPostsData = await getAllPostsData("public");
const allPostsData = await getAllPostsData(false);
return allPostsData.posts.slice(0, 3);
}),
getPosts: publicProcedure.query(async ({ ctx }) => {
const privilegeLevel = ctx.privilegeLevel;
return getAllPostsData(privilegeLevel);
const isAdmin = ctx.isAdmin;
return getAllPostsData(isAdmin);
}),
incrementPostRead: publicProcedure

View File

@@ -144,7 +144,7 @@ export const databaseRouter = createTRPCRouter({
commentID: input.commentID,
deletionType: input.deletionType,
userId: ctx.userId,
privilegeLevel: ctx.privilegeLevel
isAdmin: ctx.isAdmin
});
const commentQuery = await conn.execute({
@@ -161,7 +161,7 @@ export const databaseRouter = createTRPCRouter({
}
const isOwner = comment.commenter_id === ctx.userId;
const isAdmin = ctx.privilegeLevel === "admin";
const isAdmin = ctx.isAdmin;
console.log("[deleteComment] Authorization check:", {
isOwner,

View File

@@ -3,7 +3,7 @@ import { env } from "~/env/server";
export const infillRouter = createTRPCRouter({
getConfig: publicProcedure.query(({ ctx }) => {
if (ctx.privilegeLevel !== "admin") {
if (!ctx.isAdmin) {
return { endpoint: null, token: null };
}

View File

@@ -8,7 +8,7 @@ import { getAuthSession } from "~/server/session-helpers";
export type Context = {
event: APIEvent;
userId: string | null;
privilegeLevel: "anonymous" | "user" | "admin";
isAdmin: boolean;
};
async function createContextInner(event: APIEvent): Promise<Context> {
@@ -16,11 +16,11 @@ async function createContextInner(event: APIEvent): Promise<Context> {
const session = await getAuthSession(event.nativeEvent);
let userId: string | null = null;
let privilegeLevel: "anonymous" | "user" | "admin" = "anonymous";
let isAdmin = false;
if (session && session.userId) {
userId = session.userId;
privilegeLevel = session.isAdmin ? "admin" : "user";
isAdmin = session.isAdmin;
}
const req = event.nativeEvent.node?.req || event.nativeEvent;
@@ -56,7 +56,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
return {
event,
userId,
privilegeLevel
isAdmin
};
}
@@ -70,7 +70,7 @@ export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.userId || ctx.privilegeLevel === "anonymous") {
if (!ctx.userId) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
}
return next({
@@ -82,7 +82,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
});
const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
if (ctx.privilegeLevel !== "admin") {
if (!ctx.isAdmin) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required"

View File

@@ -6,7 +6,6 @@ import { getAuthSession } from "./session-helpers";
/**
* Check authentication status
* Consolidates getUserID, getPrivilegeLevel, and checkAuthStatus into single function
* @param event - H3Event
* @returns Object with isAuthenticated, userId, and isAdmin flags
*/

View File

@@ -7,7 +7,7 @@ import {
describe("parseConditionals", () => {
const baseContext: ConditionalContext = {
isAuthenticated: true,
privilegeLevel: "user",
isAdmin: false,
userId: "test-user",
currentDate: new Date("2025-06-01"),
featureFlags: { "beta-feature": true },
@@ -34,7 +34,7 @@ describe("parseConditionals", () => {
const anonContext: ConditionalContext = {
...baseContext,
isAuthenticated: false,
privilegeLevel: "anonymous"
isAdmin: false
};
const result = parseConditionals(html, anonContext);
expect(result).not.toContain("Secret content");
@@ -51,7 +51,7 @@ describe("parseConditionals", () => {
const adminContext: ConditionalContext = {
...baseContext,
privilegeLevel: "admin"
isAdmin: true
};
const adminResult = parseConditionals(html, adminContext);
expect(adminResult).toContain("Admin panel");
@@ -119,7 +119,7 @@ describe("parseConditionals", () => {
const anonContext: ConditionalContext = {
...baseContext,
isAuthenticated: false,
privilegeLevel: "anonymous"
isAdmin: false
};
const anonResult = parseConditionals(html, anonContext);
expect(anonResult).toContain("Not authenticated content");

View File

@@ -11,7 +11,7 @@ export function getSafeEnvVariables(): Record<string, string | undefined> {
export interface ConditionalContext {
isAuthenticated: boolean;
privilegeLevel: "admin" | "user" | "anonymous";
isAdmin: boolean;
userId: string | null;
currentDate: Date;
featureFlags: Record<string, boolean>;
@@ -194,7 +194,16 @@ function evaluatePrivilegeCondition(
value: string,
context: ConditionalContext
): boolean {
return context.privilegeLevel === value;
switch (value) {
case "admin":
return context.isAdmin;
case "user":
return context.isAuthenticated && !context.isAdmin;
case "anonymous":
return !context.isAuthenticated;
default:
return false;
}
}
/**

View File

@@ -1,228 +0,0 @@
import { ConnectionFactory } from "./database";
import { v4 as uuidV4 } from "uuid";
/**
* Migration script to add multi-provider and enhanced session support
* Run this script once to migrate existing database
*/
export async function migrateMultiAuth() {
const conn = ConnectionFactory();
try {
// Step 1: Check if UserProvider table exists
const tableCheck = await conn.execute({
sql: "SELECT name FROM sqlite_master WHERE type='table' AND name='UserProvider'"
});
if (tableCheck.rows.length > 0) {
} else {
await conn.execute(`
CREATE TABLE UserProvider (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider TEXT NOT NULL CHECK(provider IN ('email', 'google', 'github', 'apple')),
provider_user_id TEXT,
email TEXT,
display_name TEXT,
image TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
)
`);
await conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_user ON UserProvider (provider, provider_user_id)"
);
await conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_email ON UserProvider (provider, email)"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_provider_user_id ON UserProvider (user_id)"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_provider_provider ON UserProvider (provider)"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_provider_email ON UserProvider (email)"
);
}
// Step 2: Check if Session table has device columns
const sessionColumnsCheck = await conn.execute({
sql: "PRAGMA table_info(Session)"
});
const hasDeviceName = sessionColumnsCheck.rows.some(
(row: any) => row.name === "device_name"
);
if (hasDeviceName) {
} else {
await conn.execute("ALTER TABLE Session ADD COLUMN device_name TEXT");
await conn.execute("ALTER TABLE Session ADD COLUMN device_type TEXT");
await conn.execute("ALTER TABLE Session ADD COLUMN browser TEXT");
await conn.execute("ALTER TABLE Session ADD COLUMN os TEXT");
// SQLite doesn't support non-constant defaults in ALTER TABLE
// Add column with NULL default, then update existing rows
await conn.execute("ALTER TABLE Session ADD COLUMN last_active_at TEXT");
// Update existing rows to set last_active_at = last_used
await conn.execute(
"UPDATE Session SET last_active_at = COALESCE(last_used, created_at) WHERE last_active_at IS NULL"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_session_last_active ON Session (last_active_at)"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_session_user_active ON Session (user_id, revoked, last_active_at)"
);
}
// Step 3: Migrate existing users to UserProvider table
const usersResult = await conn.execute({
sql: "SELECT id, email, provider, display_name, image, apple_user_string FROM User WHERE provider IS NOT NULL"
});
let migratedCount = 0;
for (const row of usersResult.rows) {
const user = row as any;
// Skip apple provider users (they're for Life and Lineage mobile app, not website auth)
if (user.provider === "apple") {
continue;
}
// Check if already migrated
const existingProvider = await conn.execute({
sql: "SELECT id FROM UserProvider WHERE user_id = ? AND provider = ?",
args: [user.id, user.provider || "email"]
});
if (existingProvider.rows.length > 0) {
continue;
}
// Determine provider_user_id based on provider type
let providerUserId: string | null = null;
if (user.provider === "github") {
providerUserId = user.display_name;
} else if (user.provider === "google") {
providerUserId = user.email;
} else {
providerUserId = user.email;
}
try {
await conn.execute({
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
uuidV4(),
user.id,
user.provider || "email",
providerUserId,
user.email,
user.display_name,
user.image
]
});
migratedCount++;
} catch (error: any) {
console.error(
`[Migration] Failed to migrate user ${user.id}:`,
error.message
);
}
}
// Determine provider_user_id based on provider type
let providerUserId: string | null = null;
if (user.provider === "github") {
providerUserId = user.display_name;
} else if (user.provider === "google") {
providerUserId = user.email;
} else if (user.provider === "apple") {
providerUserId = user.apple_user_string;
} else {
providerUserId = user.email;
}
try {
await conn.execute({
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
uuidV4(),
user.id,
user.provider || "email",
providerUserId,
user.email,
user.display_name,
user.image
]
});
migratedCount++;
} catch (error: any) {
console.error(
`[Migration] Failed to migrate user ${user.id}:`,
error.message
);
}
}
// Step 4: Verification
const providerCount = await conn.execute({
sql: "SELECT COUNT(*) as count FROM UserProvider"
});
const multiProviderUsers = await conn.execute({
sql: `SELECT COUNT(*) as count FROM (
SELECT user_id FROM UserProvider GROUP BY user_id HAVING COUNT(*) > 1
)`
});
return {
success: true,
migratedUsers: migratedCount,
totalProviders: (providerCount.rows[0] as any).count
};
} catch (error) {
throw error;
}
}
// Run migration if called directly
if (require.main === module) {
migrateMultiAuth()
.then((result) => {
process.exit(0);
})
.catch((error) => {
process.exit(1);
});
}

View File

@@ -90,7 +90,6 @@ export function hashRefreshToken(token: string): string {
* Create a new session in database and Vinxi session
* @param event - H3Event
* @param userId - User ID
* @param isAdmin - Whether user is admin
* @param rememberMe - Whether to use extended session duration
* @param ipAddress - Client IP address
* @param userAgent - Client user agent string
@@ -101,7 +100,6 @@ export function hashRefreshToken(token: string): string {
export async function createAuthSession(
event: H3Event,
userId: string,
isAdmin: boolean,
rememberMe: boolean,
ipAddress: string,
userAgent: string,
@@ -109,6 +107,19 @@ export async function createAuthSession(
tokenFamily: string | null = null
): Promise<SessionData> {
const conn = ConnectionFactory();
// Fetch is_admin from database
const userResult = await conn.execute({
sql: "SELECT is_admin FROM User WHERE id = ?",
args: [userId]
});
if (userResult.rows.length === 0) {
throw new Error(`User not found: ${userId}`);
}
const isAdmin = userResult.rows[0].is_admin === 1;
const sessionId = uuidV4();
const family = tokenFamily || uuidV4();
const refreshToken = generateRefreshToken();
@@ -374,10 +385,10 @@ async function restoreSessionFromDB(
try {
const conn = ConnectionFactory();
// Query DB for session with all necessary data
// Query DB for session with all necessary data including is_admin
const result = await conn.execute({
sql: `SELECT s.id, s.user_id, s.token_family, s.refresh_token_hash,
s.revoked, s.expires_at, u.isAdmin
s.revoked, s.expires_at, u.is_admin
FROM Session s
JOIN User u ON s.user_id = u.id
WHERE s.id = ?`,
@@ -412,7 +423,6 @@ async function restoreSessionFromDB(
const newSession = await createAuthSession(
event,
dbSession.user_id as string,
dbSession.isAdmin === 1,
true, // Assume rememberMe=true for restoration
ipAddress,
userAgent,
@@ -678,7 +688,6 @@ export async function rotateAuthSession(
const newSessionData = await createAuthSession(
event,
oldSessionData.userId,
oldSessionData.isAdmin,
oldSessionData.rememberMe,
ipAddress,
userAgent,

View File

@@ -55,8 +55,6 @@ export interface BackupResponse {
commentParent?: number | null;
}
export type PrivilegeLevel = "admin" | "user" | "anonymous";
export type SortingMode = "newest" | "oldest" | "highest_rated" | "hot";
export type DeletionType = "user" | "admin" | "database";
@@ -64,7 +62,8 @@ export type DeletionType = "user" | "admin" | "database";
export type ModificationType = "delete" | "edit";
export interface CommentSectionWrapperProps {
privilegeLevel: PrivilegeLevel;
isAuthenticated: boolean;
isAdmin: boolean;
allComments: Comment[];
topLevelComments: Comment[];
id: number;
@@ -74,7 +73,8 @@ export interface CommentSectionWrapperProps {
}
export interface CommentSectionProps {
privilegeLevel: PrivilegeLevel;
isAuthenticated: boolean;
isAdmin: boolean;
postID: number;
allComments: Comment[];
topLevelComments: Comment[];
@@ -101,7 +101,8 @@ export interface CommentBlockProps {
recursionCount: number;
allComments: Comment[] | undefined;
child_comments: Comment[] | undefined;
privilegeLevel: PrivilegeLevel;
isAuthenticated: boolean;
isAdmin: boolean;
currentUserID: string;
reactionMap: Map<number, CommentReaction[]>;
level: number;
@@ -124,7 +125,7 @@ export interface CommentBlockProps {
export interface CommentInputBlockProps {
isReply: boolean;
parent_id?: number;
privilegeLevel: PrivilegeLevel;
isAuthenticated: boolean;
post_id: number;
socket: WebSocket | undefined;
currentUserID: string;
@@ -134,7 +135,8 @@ export interface CommentInputBlockProps {
export interface CommentSortingProps {
topLevelComments: Comment[];
privilegeLevel: PrivilegeLevel;
isAuthenticated: boolean;
isAdmin: boolean;
postID: number;
allComments: Comment[];
reactionMap: Map<number, CommentReaction[]>;
@@ -170,14 +172,14 @@ export interface ReactionBarProps {
currentUserID: string;
commentID: number;
reactions: CommentReaction[];
privilegeLevel: PrivilegeLevel;
isAuthenticated: boolean;
showingReactionOptions: boolean;
commentReaction: (reactionType: ReactionType, commentID: number) => void;
}
export interface CommentDeletionPromptProps {
isOpen: boolean;
privilegeLevel: PrivilegeLevel;
isAdmin: boolean;
commentID: number;
commenterID: string;
deleteComment: (