v3: added playlist moderation permission
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s

This commit is contained in:
khannurien
2026-06-28 06:37:23 +00:00
parent 4fb5caa146
commit 551e29c564
8 changed files with 99 additions and 66 deletions

View File

@@ -2,11 +2,16 @@ import { type AuthPayload, type Role } from "../model/interfaces.ts";
// Named capabilities checked at enforcement points. Extend this union as new // Named capabilities checked at enforcement points. Extend this union as new
// gated capabilities are added (e.g. "invite:manage"). // gated capabilities are added (e.g. "invite:manage").
export type Permission = "dump:moderate" | "comment:moderate" | "user:manage"; export type Permission =
| "dump:moderate"
| "comment:moderate"
| "playlist:moderate"
| "user:manage";
const ALL_PERMISSIONS: readonly Permission[] = [ const ALL_PERMISSIONS: readonly Permission[] = [
"dump:moderate", "dump:moderate",
"comment:moderate", "comment:moderate",
"playlist:moderate",
"user:manage", "user:manage",
]; ];
@@ -14,7 +19,7 @@ const ALL_PERMISSIONS: readonly Permission[] = [
// and future); `moderator` gets the content-moderation permissions only. // and future); `moderator` gets the content-moderation permissions only.
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = { export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
admin: ALL_PERMISSIONS, admin: ALL_PERMISSIONS,
moderator: ["dump:moderate", "comment:moderate"], moderator: ["dump:moderate", "comment:moderate", "playlist:moderate"],
user: [], user: [],
}; };

View File

@@ -8,6 +8,7 @@ import {
isUpdatePlaylistRequest, isUpdatePlaylistRequest,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { authMiddleware, type AuthState } from "../middleware/auth.ts"; import { authMiddleware, type AuthState } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts";
import { import {
addDumpToPlaylist, addDumpToPlaylist,
createPlaylist, createPlaylist,
@@ -72,13 +73,18 @@ router.put("/:playlistId", authMiddleware, async (ctx) => {
ctx.params.playlistId, ctx.params.playlistId,
body, body,
ctx.state.user.userId, ctx.state.user.userId,
can(ctx.state.user, "playlist:moderate"),
); );
ctx.response.body = { success: true, data: playlist }; ctx.response.body = { success: true, data: playlist };
}); });
// DELETE /api/playlists/:playlistId // DELETE /api/playlists/:playlistId
router.delete("/:playlistId", authMiddleware, (ctx) => { router.delete("/:playlistId", authMiddleware, (ctx) => {
deletePlaylist(ctx.params.playlistId, ctx.state.user.userId); deletePlaylist(
ctx.params.playlistId,
ctx.state.user.userId,
can(ctx.state.user, "playlist:moderate"),
);
ctx.response.status = 204; ctx.response.status = 204;
}); });
@@ -88,6 +94,7 @@ router.post("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
ctx.params.playlistId, ctx.params.playlistId,
ctx.params.dumpId, ctx.params.dumpId,
ctx.state.user.userId, ctx.state.user.userId,
can(ctx.state.user, "playlist:moderate"),
); );
ctx.response.status = 204; ctx.response.status = 204;
}); });
@@ -98,6 +105,7 @@ router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
ctx.params.playlistId, ctx.params.playlistId,
ctx.params.dumpId, ctx.params.dumpId,
ctx.state.user.userId, ctx.state.user.userId,
can(ctx.state.user, "playlist:moderate"),
); );
ctx.response.status = 204; ctx.response.status = 204;
}); });
@@ -129,6 +137,7 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => {
ctx.params.playlistId, ctx.params.playlistId,
mime, mime,
ctx.state.user.userId, ctx.state.user.userId,
can(ctx.state.user, "playlist:moderate"),
); );
const filePath = `${PLAYLIST_IMAGES_DIR}/${playlist.id}`; const filePath = `${PLAYLIST_IMAGES_DIR}/${playlist.id}`;
await Deno.mkdir(PLAYLIST_IMAGES_DIR, { recursive: true }); await Deno.mkdir(PLAYLIST_IMAGES_DIR, { recursive: true });
@@ -166,7 +175,12 @@ router.put("/:playlistId/order", authMiddleware, async (ctx) => {
"Invalid request", "Invalid request",
); );
} }
reorderPlaylist(ctx.params.playlistId, body.dumpIds, ctx.state.user.userId); reorderPlaylist(
ctx.params.playlistId,
body.dumpIds,
ctx.state.user.userId,
can(ctx.state.user, "playlist:moderate"),
);
ctx.response.body = { success: true, data: null }; ctx.response.body = { success: true, data: null };
}); });

View File

@@ -159,10 +159,11 @@ export function updatePlaylist(
playlistId: string, playlistId: string,
req: UpdatePlaylistRequest, req: UpdatePlaylistRequest,
requestingUserId: string, requestingUserId: string,
canModerate: boolean,
): Playlist { ): Playlist {
const playlist = getPlaylistById(playlistId); const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId) { if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
} }
@@ -212,10 +213,11 @@ export function updatePlaylist(
export function deletePlaylist( export function deletePlaylist(
playlistId: string, playlistId: string,
requestingUserId: string, requestingUserId: string,
canModerate: boolean,
): void { ): void {
const playlist = getPlaylistById(playlistId); const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId) { if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
} }
@@ -227,9 +229,10 @@ export function setPlaylistImage(
playlistId: string, playlistId: string,
imageMime: string, imageMime: string,
requestingUserId: string, requestingUserId: string,
canModerate: boolean,
): Playlist { ): Playlist {
const playlist = getPlaylistById(playlistId); const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId) { if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
} }
db.prepare(`UPDATE playlists SET image_mime = ? WHERE id = ?;`).run( db.prepare(`UPDATE playlists SET image_mime = ? WHERE id = ?;`).run(
@@ -245,10 +248,11 @@ export function addDumpToPlaylist(
playlistId: string, playlistId: string,
dumpId: string, dumpId: string,
requestingUserId: string, requestingUserId: string,
canModerate: boolean,
): void { ): void {
const playlist = getPlaylistById(playlistId); const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId) { if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
} }
@@ -298,10 +302,11 @@ export function removeDumpFromPlaylist(
playlistId: string, playlistId: string,
dumpId: string, dumpId: string,
requestingUserId: string, requestingUserId: string,
canModerate: boolean,
): void { ): void {
const playlist = getPlaylistById(playlistId); const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId) { if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
} }
@@ -317,10 +322,11 @@ export function reorderPlaylist(
playlistId: string, playlistId: string,
dumpIds: string[], dumpIds: string[],
requestingUserId: string, requestingUserId: string,
canModerate: boolean,
): void { ): void {
const playlist = getPlaylistById(playlistId); const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId) { if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
} }

View File

@@ -42,7 +42,7 @@ msgstr "{label} ({count})"
msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
#: src/pages/PlaylistDetail.tsx:560 #: src/pages/PlaylistDetail.tsx:561
#: src/pages/UserPublicProfile.tsx:732 #: src/pages/UserPublicProfile.tsx:732
msgid "← Back" msgid "← Back"
msgstr "← Back" msgstr "← Back"
@@ -180,13 +180,13 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/components/form/FormActions.tsx:32 #: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:359 #: src/pages/Dump.tsx:359
#: src/pages/DumpEdit.tsx:433 #: src/pages/DumpEdit.tsx:433
#: src/pages/PlaylistDetail.tsx:908 #: src/pages/PlaylistDetail.tsx:911
#: src/pages/UserPublicProfile.tsx:1637 #: src/pages/UserPublicProfile.tsx:1637
#: src/pages/UserPublicProfile.tsx:1707 #: src/pages/UserPublicProfile.tsx:1707
msgid "Cancel" msgid "Cancel"
msgstr "Cancel" msgstr "Cancel"
#: src/pages/PlaylistDetail.tsx:733 #: src/pages/PlaylistDetail.tsx:736
msgid "Cancel removal" msgid "Cancel removal"
msgstr "Cancel removal" msgstr "Cancel removal"
@@ -278,7 +278,7 @@ msgstr "Default tab"
#: src/components/CommentThread.tsx:376 #: src/components/CommentThread.tsx:376
#: src/components/CommentThread.tsx:382 #: src/components/CommentThread.tsx:382
#: src/components/ConfirmModal.tsx:16 #: src/components/ConfirmModal.tsx:16
#: src/pages/PlaylistDetail.tsx:904 #: src/pages/PlaylistDetail.tsx:907
msgid "Delete" msgid "Delete"
msgstr "Delete" msgstr "Delete"
@@ -288,7 +288,7 @@ msgid "Delete dump"
msgstr "Delete dump" msgstr "Delete dump"
#: src/components/PlaylistCard.tsx:109 #: src/components/PlaylistCard.tsx:109
#: src/pages/PlaylistDetail.tsx:746 #: src/pages/PlaylistDetail.tsx:749
#: src/pages/UserPlaylists.tsx:465 #: src/pages/UserPlaylists.tsx:465
msgid "Delete playlist" msgid "Delete playlist"
msgstr "Delete playlist" msgstr "Delete playlist"
@@ -301,13 +301,13 @@ msgstr "Delete this comment?"
msgid "Delete this dump? This cannot be undone." msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone." msgstr "Delete this dump? This cannot be undone."
#: src/pages/PlaylistDetail.tsx:745 #: src/pages/PlaylistDetail.tsx:748
#: src/pages/UserPlaylists.tsx:464 #: src/pages/UserPlaylists.tsx:464
msgid "Delete this playlist? This cannot be undone." msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone."
#: src/components/PlaylistCreateForm.tsx:78 #: src/components/PlaylistCreateForm.tsx:78
#: src/pages/PlaylistDetail.tsx:865 #: src/pages/PlaylistDetail.tsx:868
msgid "Description (optional)" msgid "Description (optional)"
msgstr "Description (optional)" msgstr "Description (optional)"
@@ -353,7 +353,7 @@ msgstr "Earlier"
#: src/components/CommentThread.tsx:367 #: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:459 #: src/pages/Dump.tsx:459
#: src/pages/PlaylistDetail.tsx:613 #: src/pages/PlaylistDetail.tsx:616
msgid "Edit" msgid "Edit"
msgstr "Edit" msgstr "Edit"
@@ -366,7 +366,7 @@ msgstr "Edit title"
#. placeholder {0}: relativeTime(playlist.updatedAt) #. placeholder {0}: relativeTime(playlist.updatedAt)
#: src/components/CommentThread.tsx:317 #: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:412 #: src/pages/Dump.tsx:412
#: src/pages/PlaylistDetail.tsx:652 #: src/pages/PlaylistDetail.tsx:655
msgid "edited {0}" msgid "edited {0}"
msgstr "edited {0}" msgstr "edited {0}"
@@ -375,7 +375,7 @@ msgstr "edited {0}"
#. placeholder {0}: playlist.updatedAt.toLocaleString() #. placeholder {0}: playlist.updatedAt.toLocaleString()
#: src/components/CommentThread.tsx:315 #: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:410 #: src/pages/Dump.tsx:410
#: src/pages/PlaylistDetail.tsx:649 #: src/pages/PlaylistDetail.tsx:652
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Edited {0}" msgstr "Edited {0}"
@@ -424,7 +424,7 @@ msgstr "Failed to post comment"
msgid "Failed to post reply" msgid "Failed to post reply"
msgstr "Failed to post reply" msgstr "Failed to post reply"
#: src/pages/PlaylistDetail.tsx:896 #: src/pages/PlaylistDetail.tsx:899
#: src/pages/UserPublicProfile.tsx:1640 #: src/pages/UserPublicProfile.tsx:1640
#: src/pages/UserPublicProfile.tsx:1709 #: src/pages/UserPublicProfile.tsx:1709
msgid "Failed to save" msgid "Failed to save"
@@ -595,7 +595,7 @@ msgstr "Loading dump…"
msgid "Loading more…" msgid "Loading more…"
msgstr "Loading more…" msgstr "Loading more…"
#: src/pages/PlaylistDetail.tsx:544 #: src/pages/PlaylistDetail.tsx:545
msgid "Loading playlist…" msgid "Loading playlist…"
msgstr "Loading playlist…" msgstr "Loading playlist…"
@@ -688,7 +688,7 @@ msgstr "New password"
msgid "New playlist" msgid "New playlist"
msgstr "New playlist" msgstr "New playlist"
#: src/pages/PlaylistDetail.tsx:668 #: src/pages/PlaylistDetail.tsx:671
msgid "No dumps in this playlist yet." msgid "No dumps in this playlist yet."
msgstr "No dumps in this playlist yet." msgstr "No dumps in this playlist yet."
@@ -785,7 +785,7 @@ msgstr "Password updated"
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "Passwords do not match" msgstr "Passwords do not match"
#: src/pages/PlaylistDetail.tsx:851 #: src/pages/PlaylistDetail.tsx:854
msgid "Playlist title" msgid "Playlist title"
msgstr "Playlist title" msgstr "Playlist title"
@@ -825,22 +825,22 @@ msgstr "Posting…"
#: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55 #: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:418 #: src/pages/Dump.tsx:418
#: src/pages/PlaylistDetail.tsx:632 #: src/pages/PlaylistDetail.tsx:635
msgid "private" msgid "private"
msgstr "private" msgstr "private"
#: src/components/form/SegmentedField.tsx:78 #: src/components/form/SegmentedField.tsx:78
#: src/pages/PlaylistDetail.tsx:890 #: src/pages/PlaylistDetail.tsx:893
msgid "Private" msgid "Private"
msgstr "Private" msgstr "Private"
#: src/components/PlaylistCard.tsx:72 #: src/components/PlaylistCard.tsx:72
#: src/pages/PlaylistDetail.tsx:631 #: src/pages/PlaylistDetail.tsx:634
msgid "public" msgid "public"
msgstr "public" msgstr "public"
#: src/components/form/SegmentedField.tsx:77 #: src/components/form/SegmentedField.tsx:77
#: src/pages/PlaylistDetail.tsx:883 #: src/pages/PlaylistDetail.tsx:886
msgid "Public" msgid "Public"
msgstr "Public" msgstr "Public"
@@ -873,7 +873,7 @@ msgstr "Related"
msgid "Remove file" msgid "Remove file"
msgstr "Remove file" msgstr "Remove file"
#: src/pages/PlaylistDetail.tsx:723 #: src/pages/PlaylistDetail.tsx:726
msgid "Remove from playlist" msgid "Remove from playlist"
msgstr "Remove from playlist" msgstr "Remove from playlist"
@@ -918,7 +918,7 @@ msgstr "Role"
#: src/components/CommentThread.tsx:328 #: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:351 #: src/pages/Dump.tsx:351
#: src/pages/DumpEdit.tsx:436 #: src/pages/DumpEdit.tsx:436
#: src/pages/PlaylistDetail.tsx:915 #: src/pages/PlaylistDetail.tsx:918
#: src/pages/UserPublicProfile.tsx:1629 #: src/pages/UserPublicProfile.tsx:1629
#: src/pages/UserPublicProfile.tsx:1699 #: src/pages/UserPublicProfile.tsx:1699
msgid "Save" msgid "Save"
@@ -927,7 +927,7 @@ msgstr "Save"
#: src/components/ChangePasswordModal.tsx:100 #: src/components/ChangePasswordModal.tsx:100
#: src/components/CommentThread.tsx:329 #: src/components/CommentThread.tsx:329
#: src/pages/Dump.tsx:350 #: src/pages/Dump.tsx:350
#: src/pages/PlaylistDetail.tsx:911 #: src/pages/PlaylistDetail.tsx:914
#: src/pages/ResetPassword.tsx:124 #: src/pages/ResetPassword.tsx:124
#: src/pages/UserPublicProfile.tsx:1626 #: src/pages/UserPublicProfile.tsx:1626
#: src/pages/UserPublicProfile.tsx:1696 #: src/pages/UserPublicProfile.tsx:1696
@@ -1019,7 +1019,7 @@ msgstr "Title is required."
msgid "Today" msgid "Today"
msgstr "Today" msgstr "Today"
#: src/pages/PlaylistDetail.tsx:735 #: src/pages/PlaylistDetail.tsx:738
msgid "Undo" msgid "Undo"
msgstr "Undo" msgstr "Undo"

File diff suppressed because one or more lines are too long

View File

@@ -42,7 +42,7 @@ msgstr "{label} ({count})"
msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}" msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
#: src/pages/PlaylistDetail.tsx:560 #: src/pages/PlaylistDetail.tsx:561
#: src/pages/UserPublicProfile.tsx:732 #: src/pages/UserPublicProfile.tsx:732
msgid "← Back" msgid "← Back"
msgstr "← Retour" msgstr "← Retour"
@@ -180,13 +180,13 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/components/form/FormActions.tsx:32 #: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:359 #: src/pages/Dump.tsx:359
#: src/pages/DumpEdit.tsx:433 #: src/pages/DumpEdit.tsx:433
#: src/pages/PlaylistDetail.tsx:908 #: src/pages/PlaylistDetail.tsx:911
#: src/pages/UserPublicProfile.tsx:1637 #: src/pages/UserPublicProfile.tsx:1637
#: src/pages/UserPublicProfile.tsx:1707 #: src/pages/UserPublicProfile.tsx:1707
msgid "Cancel" msgid "Cancel"
msgstr "Annuler" msgstr "Annuler"
#: src/pages/PlaylistDetail.tsx:733 #: src/pages/PlaylistDetail.tsx:736
msgid "Cancel removal" msgid "Cancel removal"
msgstr "Annuler la suppression" msgstr "Annuler la suppression"
@@ -278,7 +278,7 @@ msgstr "Onglet par défaut"
#: src/components/CommentThread.tsx:376 #: src/components/CommentThread.tsx:376
#: src/components/CommentThread.tsx:382 #: src/components/CommentThread.tsx:382
#: src/components/ConfirmModal.tsx:16 #: src/components/ConfirmModal.tsx:16
#: src/pages/PlaylistDetail.tsx:904 #: src/pages/PlaylistDetail.tsx:907
msgid "Delete" msgid "Delete"
msgstr "Supprimer" msgstr "Supprimer"
@@ -288,7 +288,7 @@ msgid "Delete dump"
msgstr "Supprimer la reco" msgstr "Supprimer la reco"
#: src/components/PlaylistCard.tsx:109 #: src/components/PlaylistCard.tsx:109
#: src/pages/PlaylistDetail.tsx:746 #: src/pages/PlaylistDetail.tsx:749
#: src/pages/UserPlaylists.tsx:465 #: src/pages/UserPlaylists.tsx:465
msgid "Delete playlist" msgid "Delete playlist"
msgstr "Supprimer la collection" msgstr "Supprimer la collection"
@@ -301,13 +301,13 @@ msgstr "Supprimer ce commentaire ?"
msgid "Delete this dump? This cannot be undone." msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible." msgstr "Supprimer cette reco ? Cette action est irréversible."
#: src/pages/PlaylistDetail.tsx:745 #: src/pages/PlaylistDetail.tsx:748
#: src/pages/UserPlaylists.tsx:464 #: src/pages/UserPlaylists.tsx:464
msgid "Delete this playlist? This cannot be undone." msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible." msgstr "Supprimer cette collection ? Cette action est irréversible."
#: src/components/PlaylistCreateForm.tsx:78 #: src/components/PlaylistCreateForm.tsx:78
#: src/pages/PlaylistDetail.tsx:865 #: src/pages/PlaylistDetail.tsx:868
msgid "Description (optional)" msgid "Description (optional)"
msgstr "Description (facultatif)" msgstr "Description (facultatif)"
@@ -353,7 +353,7 @@ msgstr "Plus tôt"
#: src/components/CommentThread.tsx:367 #: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:459 #: src/pages/Dump.tsx:459
#: src/pages/PlaylistDetail.tsx:613 #: src/pages/PlaylistDetail.tsx:616
msgid "Edit" msgid "Edit"
msgstr "Modifier" msgstr "Modifier"
@@ -366,7 +366,7 @@ msgstr "Modifier le titre"
#. placeholder {0}: relativeTime(playlist.updatedAt) #. placeholder {0}: relativeTime(playlist.updatedAt)
#: src/components/CommentThread.tsx:317 #: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:412 #: src/pages/Dump.tsx:412
#: src/pages/PlaylistDetail.tsx:652 #: src/pages/PlaylistDetail.tsx:655
msgid "edited {0}" msgid "edited {0}"
msgstr "modifié {0}" msgstr "modifié {0}"
@@ -375,7 +375,7 @@ msgstr "modifié {0}"
#. placeholder {0}: playlist.updatedAt.toLocaleString() #. placeholder {0}: playlist.updatedAt.toLocaleString()
#: src/components/CommentThread.tsx:315 #: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:410 #: src/pages/Dump.tsx:410
#: src/pages/PlaylistDetail.tsx:649 #: src/pages/PlaylistDetail.tsx:652
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Modifié le {0}" msgstr "Modifié le {0}"
@@ -424,7 +424,7 @@ msgstr "Impossible de publier le commentaire"
msgid "Failed to post reply" msgid "Failed to post reply"
msgstr "Impossible de publier la réponse" msgstr "Impossible de publier la réponse"
#: src/pages/PlaylistDetail.tsx:896 #: src/pages/PlaylistDetail.tsx:899
#: src/pages/UserPublicProfile.tsx:1640 #: src/pages/UserPublicProfile.tsx:1640
#: src/pages/UserPublicProfile.tsx:1709 #: src/pages/UserPublicProfile.tsx:1709
msgid "Failed to save" msgid "Failed to save"
@@ -595,7 +595,7 @@ msgstr "Chargement de la reco…"
msgid "Loading more…" msgid "Loading more…"
msgstr "Chargement…" msgstr "Chargement…"
#: src/pages/PlaylistDetail.tsx:544 #: src/pages/PlaylistDetail.tsx:545
msgid "Loading playlist…" msgid "Loading playlist…"
msgstr "Chargement de la collection…" msgstr "Chargement de la collection…"
@@ -688,7 +688,7 @@ msgstr "Nouveau mot de passe"
msgid "New playlist" msgid "New playlist"
msgstr "Nouvelle collection" msgstr "Nouvelle collection"
#: src/pages/PlaylistDetail.tsx:668 #: src/pages/PlaylistDetail.tsx:671
msgid "No dumps in this playlist yet." msgid "No dumps in this playlist yet."
msgstr "Aucune reco dans cette collection pour l'instant." msgstr "Aucune reco dans cette collection pour l'instant."
@@ -785,7 +785,7 @@ msgstr "Mot de passe mis à jour"
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "Les mots de passe ne correspondent pas" msgstr "Les mots de passe ne correspondent pas"
#: src/pages/PlaylistDetail.tsx:851 #: src/pages/PlaylistDetail.tsx:854
msgid "Playlist title" msgid "Playlist title"
msgstr "Titre de la collection" msgstr "Titre de la collection"
@@ -825,22 +825,22 @@ msgstr "Publication…"
#: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55 #: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:418 #: src/pages/Dump.tsx:418
#: src/pages/PlaylistDetail.tsx:632 #: src/pages/PlaylistDetail.tsx:635
msgid "private" msgid "private"
msgstr "privé" msgstr "privé"
#: src/components/form/SegmentedField.tsx:78 #: src/components/form/SegmentedField.tsx:78
#: src/pages/PlaylistDetail.tsx:890 #: src/pages/PlaylistDetail.tsx:893
msgid "Private" msgid "Private"
msgstr "Privé" msgstr "Privé"
#: src/components/PlaylistCard.tsx:72 #: src/components/PlaylistCard.tsx:72
#: src/pages/PlaylistDetail.tsx:631 #: src/pages/PlaylistDetail.tsx:634
msgid "public" msgid "public"
msgstr "public" msgstr "public"
#: src/components/form/SegmentedField.tsx:77 #: src/components/form/SegmentedField.tsx:77
#: src/pages/PlaylistDetail.tsx:883 #: src/pages/PlaylistDetail.tsx:886
msgid "Public" msgid "Public"
msgstr "Public" msgstr "Public"
@@ -873,7 +873,7 @@ msgstr "Connexe"
msgid "Remove file" msgid "Remove file"
msgstr "Supprimer le fichier" msgstr "Supprimer le fichier"
#: src/pages/PlaylistDetail.tsx:723 #: src/pages/PlaylistDetail.tsx:726
msgid "Remove from playlist" msgid "Remove from playlist"
msgstr "Retirer de la collection" msgstr "Retirer de la collection"
@@ -918,7 +918,7 @@ msgstr "Rôle"
#: src/components/CommentThread.tsx:328 #: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:351 #: src/pages/Dump.tsx:351
#: src/pages/DumpEdit.tsx:436 #: src/pages/DumpEdit.tsx:436
#: src/pages/PlaylistDetail.tsx:915 #: src/pages/PlaylistDetail.tsx:918
#: src/pages/UserPublicProfile.tsx:1629 #: src/pages/UserPublicProfile.tsx:1629
#: src/pages/UserPublicProfile.tsx:1699 #: src/pages/UserPublicProfile.tsx:1699
msgid "Save" msgid "Save"
@@ -927,7 +927,7 @@ msgstr "Enregistrer"
#: src/components/ChangePasswordModal.tsx:100 #: src/components/ChangePasswordModal.tsx:100
#: src/components/CommentThread.tsx:329 #: src/components/CommentThread.tsx:329
#: src/pages/Dump.tsx:350 #: src/pages/Dump.tsx:350
#: src/pages/PlaylistDetail.tsx:911 #: src/pages/PlaylistDetail.tsx:914
#: src/pages/ResetPassword.tsx:124 #: src/pages/ResetPassword.tsx:124
#: src/pages/UserPublicProfile.tsx:1626 #: src/pages/UserPublicProfile.tsx:1626
#: src/pages/UserPublicProfile.tsx:1696 #: src/pages/UserPublicProfile.tsx:1696
@@ -1019,7 +1019,7 @@ msgstr "Un titre est requis."
msgid "Today" msgid "Today"
msgstr "Aujourd'hui" msgstr "Aujourd'hui"
#: src/pages/PlaylistDetail.tsx:735 #: src/pages/PlaylistDetail.tsx:738
msgid "Undo" msgid "Undo"
msgstr "Annuler" msgstr "Annuler"
@@ -1068,7 +1068,7 @@ msgstr "Utilisateur"
#: src/pages/UserPublicProfile.tsx:1246 #: src/pages/UserPublicProfile.tsx:1246
msgid "User management" msgid "User management"
msgstr "Gestion des utilisateurs" msgstr "Gestion utilisateur"
#: src/components/UserMenu.tsx:37 #: src/components/UserMenu.tsx:37
msgid "User menu" msgid "User menu"

View File

@@ -25,6 +25,7 @@ import {
deserializePlaylistWithDumps, deserializePlaylistWithDumps,
} from "../model.ts"; } from "../model.ts";
import { playlistUrl } from "../utils/urls.ts"; import { playlistUrl } from "../utils/urls.ts";
import { can } from "../utils/permissions.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { relativeTime } from "../utils/relativeTime.ts"; import { relativeTime } from "../utils/relativeTime.ts";
@@ -566,6 +567,8 @@ export function PlaylistDetail() {
const { playlist } = state; const { playlist } = state;
const isOwner = !!user && user.id === playlist.userId; const isOwner = !!user && user.id === playlist.userId;
// Owners edit their own playlists; moderators may edit any (playlist:moderate).
const canEdit = isOwner || can(user, "playlist:moderate");
// Active dumps in playlist order; fading dumps appended so they stay visible // Active dumps in playlist order; fading dumps appended so they stay visible
const activeDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id)); const activeDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id));
@@ -604,7 +607,7 @@ export function PlaylistDetail() {
isPublic={playlist.isPublic} isPublic={playlist.isPublic}
/> />
)} )}
{isOwner && ( {canEdit && (
<button <button
type="button" type="button"
className="playlist-edit-btn" className="playlist-edit-btn"
@@ -671,7 +674,7 @@ export function PlaylistDetail() {
: ( : (
<div <div
className="playlist-dump-list" className="playlist-dump-list"
onDragOver={isOwner ? (e) => e.preventDefault() : undefined} onDragOver={canEdit ? (e) => e.preventDefault() : undefined}
> >
{visibleDumps.map((dump) => { {visibleDumps.map((dump) => {
const isActive = activeDumpIds.has(dump.id); const isActive = activeDumpIds.has(dump.id);
@@ -692,16 +695,16 @@ export function PlaylistDetail() {
? " playlist-dump-item--drag-over" ? " playlist-dump-item--drag-over"
: "" : ""
}`} }`}
draggable={isOwner && isActive} draggable={canEdit && isActive}
onDragStart={isOwner && isActive onDragStart={canEdit && isActive
? () => handleDragStart(activeIndex) ? () => handleDragStart(activeIndex)
: undefined} : undefined}
onDragOver={isOwner && isActive onDragOver={canEdit && isActive
? (e) => handleDragOver(e, activeIndex) ? (e) => handleDragOver(e, activeIndex)
: undefined} : undefined}
onDragEnd={isOwner ? handleDragEnd : undefined} onDragEnd={canEdit ? handleDragEnd : undefined}
> >
{isOwner && isActive && ( {canEdit && isActive && (
<span className="drag-handle" aria-hidden></span> <span className="drag-handle" aria-hidden></span>
)} )}
<DumpCard <DumpCard
@@ -714,7 +717,7 @@ export function PlaylistDetail() {
className={cardCls} className={cardCls}
isOwner={!!user && user.id === dump.userId} isOwner={!!user && user.id === dump.userId}
/> />
{isOwner && (isActive {canEdit && (isActive
? ( ? (
<button <button
type="button" type="button"

View File

@@ -1,17 +1,22 @@
import type { PublicUser, Role } from "../model.ts"; import type { PublicUser, Role } from "../model.ts";
// Mirror of api/lib/permissions.ts. Keep the two in sync. // Mirror of api/lib/permissions.ts. Keep the two in sync.
export type Permission = "dump:moderate" | "comment:moderate" | "user:manage"; export type Permission =
| "dump:moderate"
| "comment:moderate"
| "playlist:moderate"
| "user:manage";
const ALL_PERMISSIONS: readonly Permission[] = [ const ALL_PERMISSIONS: readonly Permission[] = [
"dump:moderate", "dump:moderate",
"comment:moderate", "comment:moderate",
"playlist:moderate",
"user:manage", "user:manage",
]; ];
const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = { const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
admin: ALL_PERMISSIONS, admin: ALL_PERMISSIONS,
moderator: ["dump:moderate", "comment:moderate"], moderator: ["dump:moderate", "comment:moderate", "playlist:moderate"],
user: [], user: [],
}; };