mirror of
https://github.com/System-End/scraps.git
synced 2026-04-19 23:22:54 +00:00
fix(shop): fix try-luck error handling and roll cost display
- ShopItemModal: check data.error/data.success instead of response.ok (backend returns 200 for all responses including errors, so response.ok was always true and errors were silently swallowed, closing the modal) - Shop page + ShopItemModal: use baseProbability for roll cost display (matching backend calculateRollCost which uses fixed base probability) - Auth: anchor session cookie regex to prevent matching wrong cookies - Auth: keep dev-mode reduced OAuth scopes
This commit is contained in:
parent
0963b78cdb
commit
42dd5eb229
3 changed files with 234 additions and 210 deletions
|
|
@ -1,258 +1,279 @@
|
|||
import { eq, and, gt, sql } from "drizzle-orm"
|
||||
import { db } from "../db"
|
||||
import { usersTable } from "../schemas/users"
|
||||
import { sessionsTable } from "../schemas/sessions"
|
||||
import { getSlackProfile, getAvatarUrl } from "./slack"
|
||||
import { config } from "../config"
|
||||
import { eq, and, gt, sql } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { usersTable } from "../schemas/users";
|
||||
import { sessionsTable } from "../schemas/sessions";
|
||||
import { getSlackProfile, getAvatarUrl } from "./slack";
|
||||
import { config } from "../config";
|
||||
|
||||
const HACKCLUB_AUTH_URL = "https://auth.hackclub.com"
|
||||
const CLIENT_ID = config.hcauth.clientId
|
||||
const CLIENT_SECRET = config.hcauth.clientSecret
|
||||
const REDIRECT_URI = config.hcauth.redirectUri
|
||||
const HACKCLUB_AUTH_URL = "https://auth.hackclub.com";
|
||||
const CLIENT_ID = config.hcauth.clientId;
|
||||
const CLIENT_SECRET = config.hcauth.clientSecret;
|
||||
const REDIRECT_URI = config.hcauth.redirectUri;
|
||||
|
||||
interface OIDCTokenResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
id_token: string
|
||||
refresh_token?: string
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
id_token: string;
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
interface HackClubAddress {
|
||||
id: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
line_1?: string
|
||||
line_2?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postal_code?: string
|
||||
country?: string
|
||||
phone_number?: string
|
||||
primary?: boolean
|
||||
id: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
line_1?: string;
|
||||
line_2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
phone_number?: string;
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
interface HackClubIdentity {
|
||||
id: string
|
||||
ysws_eligible?: boolean
|
||||
verification_status?: string
|
||||
primary_email?: string
|
||||
slack_id?: string
|
||||
phone_number?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
birthday?: string
|
||||
legal_first_name?: string
|
||||
legal_last_name?: string
|
||||
addresses?: HackClubAddress[]
|
||||
id: string;
|
||||
ysws_eligible?: boolean;
|
||||
verification_status?: string;
|
||||
primary_email?: string;
|
||||
slack_id?: string;
|
||||
phone_number?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
birthday?: string;
|
||||
legal_first_name?: string;
|
||||
legal_last_name?: string;
|
||||
addresses?: HackClubAddress[];
|
||||
}
|
||||
|
||||
interface HackClubMeResponse {
|
||||
identity: HackClubIdentity
|
||||
scopes: string[]
|
||||
identity: HackClubIdentity;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export function getAuthorizationUrl(): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: "code",
|
||||
scope: config.isDev
|
||||
? "openid profile email name slack_id verification_status"
|
||||
: "openid email name profile birthdate address verification_status slack_id basic_info"
|
||||
})
|
||||
return `${HACKCLUB_AUTH_URL}/oauth/authorize?${params.toString()}`
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: "code",
|
||||
scope: config.isDev
|
||||
? "openid profile email name slack_id verification_status"
|
||||
: "openid email name profile birthdate address verification_status slack_id basic_info",
|
||||
});
|
||||
return `${HACKCLUB_AUTH_URL}/oauth/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForTokens(code: string): Promise<OIDCTokenResponse | null> {
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code,
|
||||
grant_type: "authorization_code"
|
||||
})
|
||||
export async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
): Promise<OIDCTokenResponse | null> {
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
});
|
||||
|
||||
const response = await fetch(`${HACKCLUB_AUTH_URL}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString()
|
||||
})
|
||||
const response = await fetch(`${HACKCLUB_AUTH_URL}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Token exchange failed:", await response.text())
|
||||
return null
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("Token exchange error:", error)
|
||||
return null
|
||||
if (!response.ok) {
|
||||
console.error("Token exchange failed:", await response.text());
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Token exchange error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchUserIdentity(accessToken: string): Promise<HackClubMeResponse | null> {
|
||||
try {
|
||||
const response = await fetch(`${HACKCLUB_AUTH_URL}/api/v1/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
})
|
||||
export async function fetchUserIdentity(
|
||||
accessToken: string,
|
||||
): Promise<HackClubMeResponse | null> {
|
||||
try {
|
||||
const response = await fetch(`${HACKCLUB_AUTH_URL}/api/v1/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("[AUTH] Failed to fetch user identity:", await response.text())
|
||||
return null
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("[AUTH] Error fetching user identity:", error)
|
||||
return null
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
"[AUTH] Failed to fetch user identity:",
|
||||
await response.text(),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("[AUTH] Error fetching user identity:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UserInfoResponse {
|
||||
sub: string
|
||||
email?: string
|
||||
given_name?: string
|
||||
family_name?: string
|
||||
name?: string
|
||||
birthdate?: string
|
||||
address?: {
|
||||
street_address?: string
|
||||
locality?: string
|
||||
region?: string
|
||||
postal_code?: string
|
||||
country?: string
|
||||
}
|
||||
slack_id?: string
|
||||
sub: string;
|
||||
email?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
name?: string;
|
||||
birthdate?: string;
|
||||
address?: {
|
||||
street_address?: string;
|
||||
locality?: string;
|
||||
region?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
};
|
||||
slack_id?: string;
|
||||
}
|
||||
|
||||
export async function fetchUserInfo(accessToken: string): Promise<UserInfoResponse | null> {
|
||||
try {
|
||||
const response = await fetch(`${HACKCLUB_AUTH_URL}/oauth/userinfo`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
})
|
||||
export async function fetchUserInfo(
|
||||
accessToken: string,
|
||||
): Promise<UserInfoResponse | null> {
|
||||
try {
|
||||
const response = await fetch(`${HACKCLUB_AUTH_URL}/oauth/userinfo`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch {
|
||||
return null
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrUpdateUser(identity: HackClubIdentity, tokens: OIDCTokenResponse) {
|
||||
// Only block if explicitly false (undefined means pending verification)
|
||||
if (identity.ysws_eligible === false) {
|
||||
throw new Error("not-eligible")
|
||||
export async function createOrUpdateUser(
|
||||
identity: HackClubIdentity,
|
||||
tokens: OIDCTokenResponse,
|
||||
) {
|
||||
// Only block if explicitly false (undefined means pending verification)
|
||||
if (identity.ysws_eligible === false) {
|
||||
throw new Error("not-eligible");
|
||||
}
|
||||
|
||||
let username: string | null = null;
|
||||
let avatarUrl: string | null = null;
|
||||
|
||||
if (identity.slack_id && config.slackBotToken) {
|
||||
const slackProfile = await getSlackProfile(
|
||||
identity.slack_id,
|
||||
config.slackBotToken,
|
||||
);
|
||||
if (slackProfile) {
|
||||
username = slackProfile.display_name || slackProfile.real_name || null;
|
||||
avatarUrl = getAvatarUrl(slackProfile);
|
||||
console.log("[AUTH] Slack profile fetched:", { username, avatarUrl });
|
||||
}
|
||||
}
|
||||
|
||||
let username: string | null = null
|
||||
let avatarUrl: string | null = null
|
||||
// Use UPSERT to avoid race condition with concurrent logins
|
||||
const [user] = await db
|
||||
.insert(usersTable)
|
||||
.values({
|
||||
sub: identity.id,
|
||||
slackId: identity.slack_id,
|
||||
username,
|
||||
email: identity.primary_email || "",
|
||||
avatar: avatarUrl,
|
||||
phone: identity.phone_number || null,
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
idToken: tokens.id_token,
|
||||
verificationStatus: identity.verification_status,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: usersTable.sub,
|
||||
set: {
|
||||
username,
|
||||
email: sql`COALESCE(${identity.primary_email || null}, ${usersTable.email})`,
|
||||
slackId: identity.slack_id,
|
||||
avatar: sql`COALESCE(${avatarUrl}, ${usersTable.avatar})`,
|
||||
phone: sql`COALESCE(${identity.phone_number || null}, ${usersTable.phone})`,
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
idToken: tokens.id_token,
|
||||
verificationStatus: identity.verification_status,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (identity.slack_id && config.slackBotToken) {
|
||||
const slackProfile = await getSlackProfile(identity.slack_id, config.slackBotToken)
|
||||
if (slackProfile) {
|
||||
username = slackProfile.display_name || slackProfile.real_name || null
|
||||
avatarUrl = getAvatarUrl(slackProfile)
|
||||
console.log("[AUTH] Slack profile fetched:", { username, avatarUrl })
|
||||
}
|
||||
}
|
||||
|
||||
// Use UPSERT to avoid race condition with concurrent logins
|
||||
const [user] = await db
|
||||
.insert(usersTable)
|
||||
.values({
|
||||
sub: identity.id,
|
||||
slackId: identity.slack_id,
|
||||
username,
|
||||
email: identity.primary_email || "",
|
||||
avatar: avatarUrl,
|
||||
phone: identity.phone_number || null,
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
idToken: tokens.id_token,
|
||||
verificationStatus: identity.verification_status
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: usersTable.sub,
|
||||
set: {
|
||||
username,
|
||||
email: sql`COALESCE(${identity.primary_email || null}, ${usersTable.email})`,
|
||||
slackId: identity.slack_id,
|
||||
avatar: sql`COALESCE(${avatarUrl}, ${usersTable.avatar})`,
|
||||
phone: sql`COALESCE(${identity.phone_number || null}, ${usersTable.phone})`,
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
idToken: tokens.id_token,
|
||||
verificationStatus: identity.verification_status,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
.returning()
|
||||
|
||||
return user
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function createSession(userId: number): Promise<string> {
|
||||
const token = crypto.randomUUID()
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
await db.insert(sessionsTable).values({
|
||||
token,
|
||||
userId,
|
||||
expiresAt
|
||||
})
|
||||
|
||||
return token
|
||||
const token = crypto.randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.insert(sessionsTable).values({
|
||||
token,
|
||||
userId,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function getSessionUserId(token: string): Promise<number | null> {
|
||||
const session = await db
|
||||
.select()
|
||||
.from(sessionsTable)
|
||||
.where(and(
|
||||
eq(sessionsTable.token, token),
|
||||
gt(sessionsTable.expiresAt, new Date())
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (!session[0]) return null
|
||||
return session[0].userId
|
||||
const session = await db
|
||||
.select()
|
||||
.from(sessionsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionsTable.token, token),
|
||||
gt(sessionsTable.expiresAt, new Date()),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!session[0]) return null;
|
||||
return session[0].userId;
|
||||
}
|
||||
|
||||
export async function deleteSession(token: string): Promise<void> {
|
||||
await db.delete(sessionsTable).where(eq(sessionsTable.token, token))
|
||||
await db.delete(sessionsTable).where(eq(sessionsTable.token, token));
|
||||
}
|
||||
|
||||
export async function getUserFromSession(headers: Record<string, string | undefined>) {
|
||||
const cookie = headers.cookie || ""
|
||||
const match = cookie.match(/(?:^|;\s*)session=([^;]+)/)
|
||||
if (!match) return null
|
||||
export async function getUserFromSession(
|
||||
headers: Record<string, string | undefined>,
|
||||
) {
|
||||
const cookie = headers.cookie || "";
|
||||
const match = cookie.match(/(?:^|;\s*)session=([^;]+)/);
|
||||
if (!match) return null;
|
||||
|
||||
const userId = await getSessionUserId(match[1])
|
||||
if (!userId) return null
|
||||
const userId = await getSessionUserId(match[1]);
|
||||
if (!userId) return null;
|
||||
|
||||
const user = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId))
|
||||
.limit(1)
|
||||
const user = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId))
|
||||
.limit(1);
|
||||
|
||||
return user[0] || null
|
||||
return user[0] || null;
|
||||
}
|
||||
|
||||
export async function checkUserEligibility(accessToken: string): Promise<{ yswsEligible: boolean; verificationStatus: string } | null> {
|
||||
const identity = await fetchUserIdentity(accessToken)
|
||||
if (!identity) return null
|
||||
export async function checkUserEligibility(
|
||||
accessToken: string,
|
||||
): Promise<{ yswsEligible: boolean; verificationStatus: string } | null> {
|
||||
const identity = await fetchUserIdentity(accessToken);
|
||||
if (!identity) return null;
|
||||
|
||||
return {
|
||||
yswsEligible: identity.identity.ysws_eligible ?? false,
|
||||
verificationStatus: identity.identity.verification_status ?? 'unknown'
|
||||
}
|
||||
return {
|
||||
yswsEligible: identity.identity.ysws_eligible ?? false,
|
||||
verificationStatus: identity.identity.verification_status ?? "unknown",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
let showConfirmation = $state(false);
|
||||
let localHearted = $state(item.userHearted);
|
||||
let localHeartCount = $state(item.heartCount);
|
||||
let rollCost = $derived(Math.max(1, Math.round(item.price * (item.effectiveProbability / 100))));
|
||||
let rollCost = $derived(Math.max(1, Math.round(item.price * (item.baseProbability / 100))));
|
||||
let canAfford = $derived($userScrapsStore >= rollCost);
|
||||
let alertMessage = $state<string | null>(null);
|
||||
let alertType = $state<'error' | 'info'>('info');
|
||||
|
|
@ -142,7 +142,7 @@
|
|||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response.ok || data.error || !data.success) {
|
||||
alertType = 'error';
|
||||
alertMessage = data.error || $t.shop.failedToTryLuck;
|
||||
return;
|
||||
|
|
@ -151,8 +151,11 @@
|
|||
await refreshUserScraps();
|
||||
if (data.won) {
|
||||
onTryLuck(data.orderId);
|
||||
} else {
|
||||
} else if (data.consolationOrderId) {
|
||||
onConsolation(data.consolationOrderId, data.rolled, Math.floor(data.effectiveProbability));
|
||||
} else {
|
||||
alertType = 'error';
|
||||
alertMessage = $t.shop.somethingWentWrong;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to try luck:', e);
|
||||
|
|
|
|||
|
|
@ -72,8 +72,8 @@
|
|||
return items.sort((a, b) => b.effectiveProbability - a.effectiveProbability);
|
||||
} else if (sortBy === 'cost') {
|
||||
return items.sort((a, b) => {
|
||||
const costA = Math.max(1, Math.round(a.price * (a.effectiveProbability / 100)));
|
||||
const costB = Math.max(1, Math.round(b.price * (b.effectiveProbability / 100)));
|
||||
const costA = Math.max(1, Math.round(a.price * (a.baseProbability / 100)));
|
||||
const costB = Math.max(1, Math.round(b.price * (b.baseProbability / 100)));
|
||||
return costA - costB;
|
||||
});
|
||||
}
|
||||
|
|
@ -268,7 +268,7 @@
|
|||
<!-- Items Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{#each sortedItems as item (item.id)}
|
||||
{@const rollCost = Math.max(1, Math.round(item.price * (item.effectiveProbability / 100)))}
|
||||
{@const rollCost = Math.max(1, Math.round(item.price * (item.baseProbability / 100)))}
|
||||
<button
|
||||
onclick={() => (selectedItem = item)}
|
||||
class="relative cursor-pointer overflow-hidden rounded-2xl border-4 border-black p-4 text-left transition-all hover:border-dashed {item.count ===
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue