Skip to content

Commit 0195f28

Browse files
Simplify settings page and secure Twitch auth storage
1 parent c1e7f3c commit 0195f28

File tree

66 files changed

+5770
-961
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

66 files changed

+5770
-961
lines changed

.env.deploy.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ TWITCH_CLIENT_ID=
1212
# Twitch Extension client ID for the panel extension.
1313
TWITCH_EXTENSION_CLIENT_ID=
1414
TWITCH_CLIENT_SECRET=
15+
TWITCH_TOKEN_ENCRYPTION_SECRET=
1516
TWITCH_EVENTSUB_SECRET=
1617
TWITCH_EXTENSION_SECRET=
1718
SESSION_SECRET=

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ TWITCH_CLIENT_ID=
1313
# Twitch Extension client ID for the panel extension.
1414
TWITCH_EXTENSION_CLIENT_ID=
1515
TWITCH_CLIENT_SECRET=
16+
TWITCH_TOKEN_ENCRYPTION_SECRET=
1617
# Generate with `openssl rand -hex 32`
1718
TWITCH_EVENTSUB_SECRET=local-dev-eventsub-secret
1819
# Base64 shared secret from the Twitch Extension developer console

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ SENTRY_TRACES_SAMPLE_RATE=
9191
TWITCH_CLIENT_ID=
9292
TWITCH_EXTENSION_CLIENT_ID=
9393
TWITCH_CLIENT_SECRET=
94+
TWITCH_TOKEN_ENCRYPTION_SECRET=
9495
TWITCH_EVENTSUB_SECRET=local-dev-eventsub-secret
9596
TWITCH_EXTENSION_SECRET=
9697
SESSION_SECRET=local-dev-session-secret
@@ -116,6 +117,7 @@ To test Twitch sign-in, bot behavior, and EventSub locally, also set:
116117

117118
- `TWITCH_CLIENT_ID`
118119
- `TWITCH_CLIENT_SECRET`
120+
- `TWITCH_TOKEN_ENCRYPTION_SECRET`
119121
- `ADMIN_TWITCH_USER_IDS`
120122

121123
To test the Twitch panel extension locally, also set:
@@ -410,6 +412,7 @@ Frontend Worker:
410412
```bash
411413
echo "<TWITCH_CLIENT_ID>" | npx wrangler secret put TWITCH_CLIENT_ID --config wrangler.jsonc
412414
echo "<TWITCH_CLIENT_SECRET>" | npx wrangler secret put TWITCH_CLIENT_SECRET --config wrangler.jsonc
415+
echo "<TWITCH_TOKEN_ENCRYPTION_SECRET>" | npx wrangler secret put TWITCH_TOKEN_ENCRYPTION_SECRET --config wrangler.jsonc
413416
echo "<TWITCH_EVENTSUB_SECRET>" | npx wrangler secret put TWITCH_EVENTSUB_SECRET --config wrangler.jsonc
414417
echo "<TWITCH_EXTENSION_SECRET>" | npx wrangler secret put TWITCH_EXTENSION_SECRET --config wrangler.jsonc
415418
echo "<SESSION_SECRET>" | npx wrangler secret put SESSION_SECRET --config wrangler.jsonc
@@ -425,10 +428,11 @@ Set these non-secret panel values in `.env.deploy`:
425428
Backend Worker:
426429

427430
```bash
428-
echo "<TWITCH_CLIENT_ID>" | npx wrangler secret put TWITCH_CLIENT_ID --config wrangler.aux.jsonc
429-
echo "<TWITCH_CLIENT_SECRET>" | npx wrangler secret put TWITCH_CLIENT_SECRET --config wrangler.aux.jsonc
430-
echo "<TWITCH_EVENTSUB_SECRET>" | npx wrangler secret put TWITCH_EVENTSUB_SECRET --config wrangler.aux.jsonc
431-
echo "<SENTRY_DSN>" | npx wrangler secret put SENTRY_DSN --config wrangler.aux.jsonc
431+
echo "<TWITCH_CLIENT_ID>" | npx wrangler secret put TWITCH_CLIENT_ID --config wrangler.aux.jsonc
432+
echo "<TWITCH_CLIENT_SECRET>" | npx wrangler secret put TWITCH_CLIENT_SECRET --config wrangler.aux.jsonc
433+
echo "<TWITCH_TOKEN_ENCRYPTION_SECRET>" | npx wrangler secret put TWITCH_TOKEN_ENCRYPTION_SECRET --config wrangler.aux.jsonc
434+
echo "<TWITCH_EVENTSUB_SECRET>" | npx wrangler secret put TWITCH_EVENTSUB_SECRET --config wrangler.aux.jsonc
435+
echo "<SENTRY_DSN>" | npx wrangler secret put SENTRY_DSN --config wrangler.aux.jsonc
432436
```
433437

434438
If the Worker does not exist yet, `wrangler secret put` creates it and uploads the secret.
@@ -585,6 +589,7 @@ Set these as Codespaces repository secrets or add them to `.env` inside the Code
585589
- `TWITCH_CLIENT_ID`
586590
- `TWITCH_EXTENSION_CLIENT_ID`
587591
- `TWITCH_CLIENT_SECRET`
592+
- `TWITCH_TOKEN_ENCRYPTION_SECRET`
588593
- `TWITCH_EVENTSUB_SECRET`
589594
- `TWITCH_EXTENSION_SECRET`
590595
- `SESSION_SECRET`

docs/bot-operations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ For local development, `TWITCH_BOT_USERNAME` should usually be your dedicated te
4747

4848
`TWITCH_SCOPES` belongs to the broadcaster login flow, not the bot login flow. It should include `channel:bot` so chat replies can use Twitch's bot badge path, plus `channel:read:subscriptions`, `bits:read`, and `channel:manage:redemptions` for VIP token automation and app-owned channel point rewards. If the connected broadcaster account is missing those permissions, reconnect Twitch.
4949

50-
App-owned channel point rewards only work on Twitch Affiliate or Partner channels. If you test this flow on a channel without channel points, Twitch rejects the reward API calls and request-bot leaves the rest of the bot active.
50+
App-owned channel point rewards only work on Twitch Affiliate or Partner channels. If you test this flow on a channel without channel points, Twitch rejects the reward API calls and RockList.Live leaves the rest of the bot active.
5151

5252
2. Make sure your Twitch developer application has both redirect URIs registered:
5353

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
ALTER TABLE `channel_settings`
2+
ADD `vip_token_duration_thresholds_json` text DEFAULT '[]' NOT NULL;
3+
4+
ALTER TABLE `playlist_items`
5+
ADD `vip_token_cost` integer DEFAULT 0 NOT NULL;
6+
7+
UPDATE `playlist_items`
8+
SET `vip_token_cost` = CASE
9+
WHEN `request_kind` = 'vip' THEN 1
10+
ELSE 0
11+
END;
12+
13+
ALTER TABLE `played_songs`
14+
ADD `vip_token_cost` integer DEFAULT 0 NOT NULL;
15+
16+
UPDATE `played_songs`
17+
SET `vip_token_cost` = CASE
18+
WHEN `request_kind` = 'vip' THEN 1
19+
ELSE 0
20+
END;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
ALTER TABLE `channel_settings`
2+
ADD `vip_request_cooldown_enabled` integer DEFAULT 0 NOT NULL;
3+
4+
ALTER TABLE `channel_settings`
5+
ADD `vip_request_cooldown_minutes` integer DEFAULT 0 NOT NULL;
6+
7+
CREATE TABLE `vip_request_cooldowns` (
8+
`channel_id` text NOT NULL,
9+
`normalized_login` text NOT NULL,
10+
`twitch_user_id` text,
11+
`login` text NOT NULL,
12+
`display_name` text,
13+
`source_item_id` text NOT NULL,
14+
`cooldown_started_at` integer NOT NULL,
15+
`cooldown_expires_at` integer NOT NULL,
16+
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
17+
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
18+
PRIMARY KEY(`channel_id`, `normalized_login`),
19+
FOREIGN KEY (`channel_id`) REFERENCES `channels`(`id`) ON UPDATE no action ON DELETE no action
20+
);
21+
22+
CREATE INDEX `vip_request_cooldowns_channel_user_idx`
23+
ON `vip_request_cooldowns` (`channel_id`, `twitch_user_id`);
24+
25+
CREATE INDEX `vip_request_cooldowns_channel_source_idx`
26+
ON `vip_request_cooldowns` (`channel_id`, `source_item_id`);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE channel_settings
2+
ADD COLUMN show_pick_order_badges integer NOT NULL DEFAULT 0;

src/components/overlay-settings-panel.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { Checkbox } from "~/components/ui/checkbox";
1717
import { Input } from "~/components/ui/input";
1818
import { useLocaleTranslation } from "~/lib/i18n/client";
19+
import { getPickNumbersForQueuedItems } from "~/lib/pick-order";
1920
import { getErrorMessage, hexToRgba } from "~/lib/utils";
2021
import type { OverlaySettingsInputData } from "~/lib/validation";
2122

@@ -46,10 +47,21 @@ type PlaylistData = {
4647
requestedByLogin?: string | null;
4748
status: string;
4849
}>;
50+
showPickOrderBadges?: boolean;
4951
};
5052

5153
type ChannelPlaylistPreviewResponse = {
5254
items?: PlaylistData["items"];
55+
playedSongs?: Array<{
56+
requestedByTwitchUserId?: string | null;
57+
requestedByLogin?: string | null;
58+
requestedAt?: number | null;
59+
playedAt?: number | null;
60+
createdAt?: number | null;
61+
}>;
62+
settings?: {
63+
showPickOrderBadges?: boolean;
64+
};
5365
};
5466

5567
const defaultOverlayForm: OverlaySettingsInputData = {
@@ -135,8 +147,18 @@ export function OverlaySettingsPanel() {
135147
throw new Error(t("overlay.states.failedPreview"));
136148
}
137149

150+
const items = body?.items ?? [];
151+
const pickNumbers = getPickNumbersForQueuedItems(
152+
items,
153+
body?.playedSongs ?? []
154+
);
155+
138156
return {
139-
items: body?.items ?? [],
157+
items: items.map((item, index) => ({
158+
...item,
159+
pickNumber: pickNumbers[index] ?? null,
160+
})),
161+
showPickOrderBadges: !!body?.settings?.showPickOrderBadges,
140162
} satisfies PlaylistData;
141163
},
142164
enabled: !!overlayQuery.data?.channel.slug,
@@ -536,6 +558,9 @@ export function OverlaySettingsPanel() {
536558
})}
537559
items={previewItems}
538560
theme={previewTheme}
561+
showPickOrderBadges={
562+
playlistQuery.data?.showPickOrderBadges ?? false
563+
}
539564
/>
540565
</div>
541566
</div>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useAppLocale, useLocaleTranslation } from "~/lib/i18n/client";
2+
import { getPickBadgeAppearance, getPickBadgeLabel } from "~/lib/pick-order";
3+
import { cn } from "~/lib/utils";
4+
5+
type PickOrderBadgeVariant = "public" | "overlay" | "panel";
6+
7+
const variantClassNames: Record<PickOrderBadgeVariant, string> = {
8+
public:
9+
"inline-flex items-center border border-transparent px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white",
10+
overlay:
11+
"inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white",
12+
panel:
13+
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[8px] leading-none font-semibold uppercase tracking-[0.12em] text-white",
14+
};
15+
16+
export function PickOrderBadge(props: {
17+
pickNumber: number;
18+
variant?: PickOrderBadgeVariant;
19+
className?: string;
20+
}) {
21+
const { locale } = useAppLocale();
22+
const { t } = useLocaleTranslation("playlist");
23+
const label = getPickBadgeLabel({
24+
locale,
25+
pickNumber: props.pickNumber,
26+
translate: (key, options) => t(key, options),
27+
});
28+
const appearance = getPickBadgeAppearance(props.pickNumber);
29+
const variant = props.variant ?? "public";
30+
31+
return (
32+
<span
33+
className={cn(variantClassNames[variant], props.className)}
34+
style={{ background: appearance.background }}
35+
>
36+
{label}
37+
</span>
38+
);
39+
}

src/components/playlist-management-surface.tsx

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { AnimatePresence, motion } from "motion/react";
3232
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
3333
import { BlacklistPanel } from "~/components/blacklist-panel";
3434
import { DashboardPageHeader } from "~/components/dashboard-page-header";
35+
import { PickOrderBadge } from "~/components/pick-order-badge";
3536
import {
3637
AlertDialog,
3738
AlertDialogAction,
@@ -58,6 +59,7 @@ import {
5859
formatDate as formatLocaleDate,
5960
formatNumber,
6061
} from "~/lib/i18n/format";
62+
import { getPickNumbersForQueuedItems } from "~/lib/pick-order";
6163
import {
6264
formatPlaylistItemSummaryLine,
6365
getResolvedPlaylistCandidates,
@@ -101,6 +103,7 @@ export type PlaylistItem = {
101103
warningCode?: string;
102104
warningMessage?: string;
103105
candidateMatchesJson?: string;
106+
pickNumber?: number | null;
104107
createdAt: number;
105108
position: number;
106109
regularPosition?: number | null;
@@ -238,6 +241,7 @@ type PlaylistQueryData = {
238241
};
239242
settings?: {
240243
canManageBlacklist?: boolean;
244+
showPickOrderBadges?: boolean;
241245
};
242246
items: PlaylistItem[];
243247
playedSongs: PlayedSong[];
@@ -342,6 +346,7 @@ export function PlaylistManagementSurface(
342346
};
343347
settings?: {
344348
canManageBlacklist?: boolean;
349+
showPickOrderBadges?: boolean;
345350
};
346351
items: PlaylistItem[];
347352
playedSongs: PlayedSong[];
@@ -647,15 +652,23 @@ export function PlaylistManagementSurface(
647652
},
648653
});
649654

655+
const playedSongs = playlistQuery.data?.playedSongs ?? [];
656+
const showPickOrderBadges =
657+
!!playlistQuery.data?.settings?.showPickOrderBadges;
658+
const items = useMemo(() => {
659+
const baseItems = playlistQuery.data?.items ?? [];
660+
const pickNumbers = getPickNumbersForQueuedItems(baseItems, playedSongs);
661+
662+
return baseItems.map((item, index) => ({
663+
...item,
664+
pickNumber: pickNumbers[index] ?? null,
665+
}));
666+
}, [playedSongs, playlistQuery.data?.items]);
650667
const currentItemId = useMemo(
651-
() =>
652-
playlistQuery.data?.items?.find((item) => item.status === "current")
653-
?.id ?? null,
654-
[playlistQuery.data?.items]
668+
() => items.find((item) => item.status === "current")?.id ?? null,
669+
[items]
655670
);
656671

657-
const items = playlistQuery.data?.items ?? [];
658-
const playedSongs = playlistQuery.data?.playedSongs ?? [];
659672
const vipTokens = playlistQuery.data?.vipTokens ?? [];
660673
const blacklistArtists = playlistQuery.data?.blacklistArtists ?? [];
661674
const blacklistCharters = playlistQuery.data?.blacklistCharters ?? [];
@@ -1005,6 +1018,7 @@ export function PlaylistManagementSurface(
10051018
draggingItemId={draggingItemId}
10061019
dropTargetState={dropTargetState}
10071020
currentItemId={currentItemId}
1021+
showPickOrderBadges={showPickOrderBadges}
10081022
canManageBlacklist={canManageBlacklist}
10091023
blacklistedArtistIds={blacklistedArtistIds}
10101024
blacklistedSongIds={blacklistedSongIds}
@@ -1200,6 +1214,7 @@ export function PlaylistManagementSurface(
12001214
draggingItemId={draggingItemId}
12011215
dropTargetState={dropTargetState}
12021216
currentItemId={currentItemId}
1217+
showPickOrderBadges={showPickOrderBadges}
12031218
canManageBlacklist={canManageBlacklist}
12041219
blacklistedArtistIds={blacklistedArtistIds}
12051220
blacklistedSongIds={blacklistedSongIds}
@@ -1494,6 +1509,7 @@ function CurrentPlaylistRows(props: {
14941509
draggingItemId: string | null;
14951510
dropTargetState: { itemId: string; edge: Edge } | null;
14961511
currentItemId: string | null;
1512+
showPickOrderBadges: boolean;
14971513
canManageBlacklist: boolean;
14981514
blacklistedArtistIds: Set<number>;
14991515
blacklistedSongIds: Set<number>;
@@ -1549,6 +1565,7 @@ function CurrentPlaylistRows(props: {
15491565
draggingItemId={props.draggingItemId}
15501566
dropTargetState={props.dropTargetState}
15511567
currentItemId={props.currentItemId}
1568+
showPickOrderBadges={props.showPickOrderBadges}
15521569
isDeletingItem={props.isDeletingItem(item.id)}
15531570
isSetCurrentPending={props.isRowPending("setCurrent", item.id)}
15541571
isReturnToQueuePending={props.isRowPending(
@@ -1822,6 +1839,7 @@ function PlaylistQueueItem(props: {
18221839
draggingItemId: string | null;
18231840
dropTargetState: { itemId: string; edge: Edge } | null;
18241841
currentItemId: string | null;
1842+
showPickOrderBadges: boolean;
18251843
isDeletingItem: boolean;
18261844
isSetCurrentPending: boolean;
18271845
isReturnToQueuePending: boolean;
@@ -2006,7 +2024,7 @@ function PlaylistQueueItem(props: {
20062024
<div className="flex items-stretch">
20072025
{props.useTouchReorderControls ? (
20082026
<div className="dashboard-playlist__drag-handle inline-flex w-14 shrink-0 self-stretch border-r border-(--border) px-1 py-2">
2009-
<div className="grid w-full grid-cols-2 gap-1">
2027+
<div className="grid w-full grid-cols-1 gap-1">
20102028
<TouchReorderButton
20112029
label={t("management.item.moveTop")}
20122030
icon={ChevronsUp}
@@ -2083,6 +2101,10 @@ function PlaylistQueueItem(props: {
20832101
{t("management.item.vipBadge")}
20842102
</Badge>
20852103
) : null}
2104+
{props.showPickOrderBadges &&
2105+
props.item.pickNumber != null ? (
2106+
<PickOrderBadge pickNumber={props.item.pickNumber} />
2107+
) : null}
20862108
{isCurrentItem ? (
20872109
<Badge className="border-emerald-400/35 bg-emerald-500/15 text-emerald-200 hover:bg-emerald-500/15">
20882110
{t("management.item.playingBadge")}
@@ -2882,6 +2904,7 @@ export function PlaylistQueueItemPreview() {
28822904
draggingItemId={null}
28832905
dropTargetState={null}
28842906
currentItemId={item.status === "current" ? item.id : null}
2907+
showPickOrderBadges
28852908
isDeletingItem={false}
28862909
isSetCurrentPending={false}
28872910
isReturnToQueuePending={false}

0 commit comments

Comments
 (0)