Require admin password for destructive admin actions
This commit is contained in:
8
API.md
8
API.md
@@ -34,8 +34,10 @@ GET /api/results — leaderboard with totals, counts, averages, caller’s vote,
|
|||||||
## Admin (requires authenticated admin user)
|
## Admin (requires authenticated admin user)
|
||||||
POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases
|
POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases
|
||||||
GET /api/admin/vote-status — readiness overview (who finalized)
|
GET /api/admin/vote-status — readiness overview (who finalized)
|
||||||
|
POST /api/admin/joker — `{ playerId }` grants a vote-phase joker to the target player
|
||||||
|
POST /api/admin/player-phase — `{ playerId, phase }`; currently supports Vote→Suggest transitions only
|
||||||
|
DELETE /api/admin/players/{playerId} — `{ password }`; deletes player account plus their suggestions/votes
|
||||||
POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players
|
POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players
|
||||||
POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players
|
POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players
|
||||||
POST /api/admin/player-phase — `{ playerId, phase }`; currently supports Vote→Suggest transitions only
|
POST /api/admin/reset — `{ password }`; clear suggestions/votes, keep players, reset phases/vote-final flags
|
||||||
POST /api/admin/reset — clear suggestions/votes; keep players; reset phases/vote-final flags
|
POST /api/admin/factory-reset — `{ password }`; wipe players, suggestions, votes, state
|
||||||
POST /api/admin/factory-reset — wipe players, suggestions, votes, state
|
|
||||||
|
|||||||
@@ -21,3 +21,5 @@ public record UnlinkSuggestionsRequest(int SuggestionId);
|
|||||||
public record GrantJokerRequest(Guid PlayerId);
|
public record GrantJokerRequest(Guid PlayerId);
|
||||||
|
|
||||||
public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase);
|
public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase);
|
||||||
|
|
||||||
|
public record AdminPasswordRequest(string Password);
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ public static class AdminEndpoints
|
|||||||
|
|
||||||
admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase));
|
admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase));
|
||||||
|
|
||||||
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, AdminWorkflowService service) => await service.DeletePlayerAsync(playerId));
|
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||||
|
{
|
||||||
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null)
|
||||||
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
|
return await service.DeletePlayerAsync(playerId, player.Id, request.Password);
|
||||||
|
});
|
||||||
|
|
||||||
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||||
{
|
{
|
||||||
@@ -39,9 +46,23 @@ public static class AdminEndpoints
|
|||||||
return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId);
|
return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
admin.MapPost("/reset", async (AdminWorkflowService service) => await service.ResetAsync());
|
admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||||
|
{
|
||||||
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null)
|
||||||
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
admin.MapPost("/factory-reset", async (AdminWorkflowService service) => await service.FactoryResetAsync());
|
return await service.ResetAsync(player.Id, request.Password);
|
||||||
|
});
|
||||||
|
|
||||||
|
admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||||
|
{
|
||||||
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null)
|
||||||
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
|
return await service.FactoryResetAsync(player.Id, request.Password);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using GameList.Contracts;
|
using GameList.Contracts;
|
||||||
using GameList.Data;
|
using GameList.Data;
|
||||||
using GameList.Domain;
|
using GameList.Domain;
|
||||||
|
using GameList.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace GameList.Endpoints;
|
namespace GameList.Endpoints;
|
||||||
@@ -86,8 +87,12 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
|
return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> DeletePlayerAsync(Guid playerId)
|
public async Task<IResult> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password)
|
||||||
{
|
{
|
||||||
|
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
|
||||||
|
if (passwordError is not null)
|
||||||
|
return passwordError;
|
||||||
|
|
||||||
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
|
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.NotFoundError("Player not found.");
|
return EndpointHelpers.NotFoundError("Player not found.");
|
||||||
@@ -203,8 +208,12 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
|
return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> ResetAsync()
|
public async Task<IResult> ResetAsync(Guid adminPlayerId, string password)
|
||||||
{
|
{
|
||||||
|
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
|
||||||
|
if (passwordError is not null)
|
||||||
|
return passwordError;
|
||||||
|
|
||||||
await using var tx = await db.Database.BeginTransactionAsync();
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
await db.Votes.ExecuteDeleteAsync();
|
await db.Votes.ExecuteDeleteAsync();
|
||||||
@@ -220,8 +229,12 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
|
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> FactoryResetAsync()
|
public async Task<IResult> FactoryResetAsync(Guid adminPlayerId, string password)
|
||||||
{
|
{
|
||||||
|
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
|
||||||
|
if (passwordError is not null)
|
||||||
|
return passwordError;
|
||||||
|
|
||||||
await using var tx = await db.Database.BeginTransactionAsync();
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
await db.Votes.ExecuteDeleteAsync();
|
await db.Votes.ExecuteDeleteAsync();
|
||||||
@@ -237,4 +250,18 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
|
|
||||||
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
|
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<IResult?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
|
return EndpointHelpers.BadRequestError("Admin password is required.");
|
||||||
|
|
||||||
|
var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin);
|
||||||
|
if (admin is null)
|
||||||
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
|
return PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt)
|
||||||
|
? null
|
||||||
|
: EndpointHelpers.BadRequestError("Invalid admin password.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ namespace GameList.Tests;
|
|||||||
|
|
||||||
public class AdminTests
|
public class AdminTests
|
||||||
{
|
{
|
||||||
|
private const string AdminPassword = "Pass123!";
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Admin_vote_status_marks_ready_when_all_finalized()
|
public async Task Admin_vote_status_marks_ready_when_all_finalized()
|
||||||
{
|
{
|
||||||
@@ -134,7 +136,10 @@ public class AdminTests
|
|||||||
Score = 8
|
Score = 8
|
||||||
});
|
});
|
||||||
|
|
||||||
var resp = await admin.DeleteAsync($"/api/admin/players/{await player.GetProfileIdAsync()}");
|
var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new { password = AdminPassword })
|
||||||
|
});
|
||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
await factory.WithDbContextAsync(db =>
|
await factory.WithDbContextAsync(db =>
|
||||||
@@ -246,7 +251,7 @@ public class AdminTests
|
|||||||
await player.RegisterAsync("player");
|
await player.RegisterAsync("player");
|
||||||
await player.CreateSuggestionAsync("Keep");
|
await player.CreateSuggestionAsync("Keep");
|
||||||
|
|
||||||
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { });
|
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { password = AdminPassword });
|
||||||
reset.EnsureSuccessStatusCode();
|
reset.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
await factory.WithDbContextAsync(db =>
|
await factory.WithDbContextAsync(db =>
|
||||||
@@ -266,7 +271,7 @@ public class AdminTests
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { });
|
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { password = AdminPassword });
|
||||||
factoryReset.EnsureSuccessStatusCode();
|
factoryReset.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
await factory.WithDbContextAsync(db =>
|
await factory.WithDbContextAsync(db =>
|
||||||
@@ -514,7 +519,7 @@ public class AdminTests
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { });
|
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { password = AdminPassword });
|
||||||
reset.EnsureSuccessStatusCode();
|
reset.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
await factory.WithDbContextAsync(async db =>
|
await factory.WithDbContextAsync(async db =>
|
||||||
@@ -526,7 +531,7 @@ public class AdminTests
|
|||||||
Assert.False(state.ResultsOpen);
|
Assert.False(state.ResultsOpen);
|
||||||
});
|
});
|
||||||
|
|
||||||
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { });
|
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { password = AdminPassword });
|
||||||
factoryReset.EnsureSuccessStatusCode();
|
factoryReset.EnsureSuccessStatusCode();
|
||||||
await factory.WithDbContextAsync(async db =>
|
await factory.WithDbContextAsync(async db =>
|
||||||
{
|
{
|
||||||
@@ -534,4 +539,25 @@ public class AdminTests
|
|||||||
Assert.False(state.ResultsOpen);
|
Assert.False(state.ResultsOpen);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Destructive_admin_actions_require_valid_admin_password()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var admin = factory.CreateClientWithCookies();
|
||||||
|
await admin.RegisterAsync("admin", admin: true);
|
||||||
|
var player = factory.CreateClientWithCookies();
|
||||||
|
await player.RegisterAsync("target");
|
||||||
|
|
||||||
|
var resetWrongPassword = await admin.PostAsJsonAsync("/api/admin/reset", new { password = "wrong" });
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, resetWrongPassword.StatusCode);
|
||||||
|
|
||||||
|
var playerId = await factory.WithDbContextAsync(async db => await db.Players.Where(p => p.Username == "target").Select(p => p.Id).SingleAsync());
|
||||||
|
|
||||||
|
var deleteWrongPassword = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{playerId}")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new { password = "wrong" })
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, deleteWrongPassword.StatusCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
SPEC.md
1
SPEC.md
@@ -11,6 +11,7 @@ Help a small Discord group (4–8 players) pick a co-op game via phased flow:
|
|||||||
- Username/password login (cookie auth)
|
- Username/password login (cookie auth)
|
||||||
- Admins flagged via admin key at registration
|
- Admins flagged via admin key at registration
|
||||||
- Logout returns to the login form and clears all auth form fields
|
- Logout returns to the login form and clears all auth form fields
|
||||||
|
- Destructive admin actions (player delete, reset, factory reset) require admin password confirmation
|
||||||
- Per-user phase tracking; admins can move themselves backward, everyone can move forward (subject to admin “results open” toggle and Suggest→Vote requiring at least one own suggestion)
|
- Per-user phase tracking; admins can move themselves backward, everyone can move forward (subject to admin “results open” toggle and Suggest→Vote requiring at least one own suggestion)
|
||||||
|
|
||||||
## Suggest Phase
|
## Suggest Phase
|
||||||
|
|||||||
6
TESTS.md
6
TESTS.md
@@ -69,11 +69,11 @@ stateDiagram-v2
|
|||||||
- GET /admin/vote-status returns list ordered by display/username with suggestion counts, finalized flag, joker flag; ready/waiting derived correctly.
|
- GET /admin/vote-status returns list ordered by display/username with suggestion counts, finalized flag, joker flag; ready/waiting derived correctly.
|
||||||
- POST /admin/joker grants joker only when target in Vote; resets VotesFinal for target.
|
- POST /admin/joker grants joker only when target in Vote; resets VotesFinal for target.
|
||||||
- POST /admin/player-phase allows Vote->Suggest transitions only; rejects other targets/current phases; clears target VotesFinal.
|
- POST /admin/player-phase allows Vote->Suggest transitions only; rejects other targets/current phases; clears target VotesFinal.
|
||||||
- DELETE /admin/players/{id}: removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction.
|
- DELETE /admin/players/{id}: requires valid admin password; removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction.
|
||||||
- POST /admin/link-suggestions: only in Vote; errors on same ids/already linked/not found; re-parents groups correctly; deletes votes for affected group and unfinalizes affected players.
|
- POST /admin/link-suggestions: only in Vote; errors on same ids/already linked/not found; re-parents groups correctly; deletes votes for affected group and unfinalizes affected players.
|
||||||
- POST /admin/unlink-suggestions: only in Vote; clears parents for group, deletes votes in group, unfinalizes affected players; no-op safe when missing.
|
- POST /admin/unlink-suggestions: only in Vote; clears parents for group, deletes votes in group, unfinalizes affected players; no-op safe when missing.
|
||||||
- POST /admin/reset: wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp.
|
- POST /admin/reset: requires valid admin password; wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp.
|
||||||
- POST /admin/factory-reset: wipes all players/suggestions/votes/state; reseeds AppState with defaults; transactional.
|
- POST /admin/factory-reset: requires valid admin password; wipes all players/suggestions/votes/state; reseeds AppState with defaults; transactional.
|
||||||
|
|
||||||
### 7) Infrastructure/Helpers
|
### 7) Infrastructure/Helpers
|
||||||
- PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage).
|
- PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage).
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
|
|||||||
- Vorschläge löschen
|
- Vorschläge löschen
|
||||||
- Abstimmungsstatus einsehen (wer finalisiert hat)
|
- Abstimmungsstatus einsehen (wer finalisiert hat)
|
||||||
- Einen Spieler löschen (inklusive dessen Vorschläge und Stimmen)
|
- Einen Spieler löschen (inklusive dessen Vorschläge und Stimmen)
|
||||||
|
- Für Konto-Löschung, Zurücksetzen und Werkseinstellung das Admin-Passwort bestätigen
|
||||||
- Die Datenbank auf Werkseinstellungen zurücksetzen
|
- Die Datenbank auf Werkseinstellungen zurücksetzen
|
||||||
- Zu vorherigen Phasen zurückkehren
|
- Zu vorherigen Phasen zurückkehren
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
|
|||||||
- Delete suggestions
|
- Delete suggestions
|
||||||
- View vote readiness (who has finalized)
|
- View vote readiness (who has finalized)
|
||||||
- Delete a player (removes their suggestions and votes)
|
- Delete a player (removes their suggestions and votes)
|
||||||
|
- Confirm admin password for account deletion, reset, and factory reset
|
||||||
- Reset the database to factory defaults
|
- Reset the database to factory defaults
|
||||||
- Move backward to previous phases
|
- Move backward to previous phases
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,12 @@
|
|||||||
"admin.resultsUpdated": "Results availability updated",
|
"admin.resultsUpdated": "Results availability updated",
|
||||||
"admin.reset": "Reset (keep players)",
|
"admin.reset": "Reset (keep players)",
|
||||||
"admin.factoryReset": "Factory reset",
|
"admin.factoryReset": "Factory reset",
|
||||||
|
"admin.resetConfirmTitle": "Reset round data?",
|
||||||
|
"admin.resetConfirmBody": "This clears suggestions and votes while keeping accounts. Enter your admin password to continue.",
|
||||||
|
"admin.factoryResetConfirmTitle": "Factory reset everything?",
|
||||||
|
"admin.factoryResetConfirmBody": "This removes all players, suggestions, and votes. Enter your admin password to continue.",
|
||||||
|
"admin.confirmPasswordLabel": "Admin password",
|
||||||
|
"admin.confirmPasswordRequired": "Admin password is required.",
|
||||||
"admin.resetDone": "Reset complete",
|
"admin.resetDone": "Reset complete",
|
||||||
"admin.factoryResetDone": "Factory reset complete",
|
"admin.factoryResetDone": "Factory reset complete",
|
||||||
"admin.readyForResults": "Ready for results",
|
"admin.readyForResults": "Ready for results",
|
||||||
@@ -269,6 +275,12 @@
|
|||||||
"admin.resultsUpdated": "Ergebnisfreigabe aktualisiert",
|
"admin.resultsUpdated": "Ergebnisfreigabe aktualisiert",
|
||||||
"admin.reset": "Zurücksetzen (Spieler behalten)",
|
"admin.reset": "Zurücksetzen (Spieler behalten)",
|
||||||
"admin.factoryReset": "Werkseinstellung",
|
"admin.factoryReset": "Werkseinstellung",
|
||||||
|
"admin.resetConfirmTitle": "Rundendaten zurücksetzen?",
|
||||||
|
"admin.resetConfirmBody": "Dadurch werden Vorschläge und Stimmen gelöscht, die Konten bleiben erhalten. Gib dein Admin-Passwort ein, um fortzufahren.",
|
||||||
|
"admin.factoryResetConfirmTitle": "Alles auf Werkseinstellung setzen?",
|
||||||
|
"admin.factoryResetConfirmBody": "Dadurch werden alle Spieler, Vorschläge und Stimmen gelöscht. Gib dein Admin-Passwort ein, um fortzufahren.",
|
||||||
|
"admin.confirmPasswordLabel": "Admin-Passwort",
|
||||||
|
"admin.confirmPasswordRequired": "Admin-Passwort ist erforderlich.",
|
||||||
"admin.resetDone": "Zurücksetzen abgeschlossen",
|
"admin.resetDone": "Zurücksetzen abgeschlossen",
|
||||||
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
|
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
|
||||||
"admin.readyForResults": "Bereit für Ergebnisse",
|
"admin.readyForResults": "Bereit für Ergebnisse",
|
||||||
|
|||||||
@@ -56,12 +56,18 @@ export const api = {
|
|||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }),
|
setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }),
|
||||||
voteStatus: () => request("/api/admin/vote-status"),
|
voteStatus: () => request("/api/admin/vote-status"),
|
||||||
reset: () => request("/api/admin/reset", { method: "POST" }),
|
reset: (password) =>
|
||||||
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
|
request("/api/admin/reset", { method: "POST", body: { password } }),
|
||||||
|
factoryReset: (password) =>
|
||||||
|
request("/api/admin/factory-reset", { method: "POST", body: { password } }),
|
||||||
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
|
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
|
||||||
setPlayerPhase: (playerId, phase) =>
|
setPlayerPhase: (playerId, phase) =>
|
||||||
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
|
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
|
||||||
deletePlayer: (playerId) => request(`/api/admin/players/${playerId}`, { method: "DELETE" }),
|
deletePlayer: (playerId, password) =>
|
||||||
|
request(`/api/admin/players/${playerId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: { password },
|
||||||
|
}),
|
||||||
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
|
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
|
||||||
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
|
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
|
||||||
unlinkSuggestions: (suggestionId) =>
|
unlinkSuggestions: (suggestionId) =>
|
||||||
|
|||||||
@@ -8,14 +8,23 @@ import {
|
|||||||
renderPhasePill,
|
renderPhasePill,
|
||||||
} from "./ui.js";
|
} from "./ui.js";
|
||||||
|
|
||||||
async function adminAction(fn, successMessage, runSerializedRefresh) {
|
function openAdminPasswordModal({ title, body, confirmLabel, onConfirm }) {
|
||||||
try {
|
openConfirmModal({
|
||||||
await fn();
|
title,
|
||||||
toast(successMessage);
|
body,
|
||||||
await runSerializedRefresh();
|
confirmLabel,
|
||||||
} catch (err) {
|
confirmClass: "danger",
|
||||||
toast(err.message, true);
|
requirePassword: true,
|
||||||
}
|
passwordLabel: t("admin.confirmPasswordLabel"),
|
||||||
|
onConfirm: async (close, payload) => {
|
||||||
|
const password = (payload?.password || "").trim();
|
||||||
|
if (!password) {
|
||||||
|
toast(t("admin.confirmPasswordRequired"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onConfirm(password, close);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupAdminPanelToggle() {
|
function setupAdminPanelToggle() {
|
||||||
@@ -32,16 +41,40 @@ function setupAdminPanelToggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupResetButtons(runSerializedRefresh) {
|
function setupResetButtons(runSerializedRefresh) {
|
||||||
$("reset").addEventListener("click", () =>
|
$("reset").addEventListener("click", () => {
|
||||||
adminAction(adminApi.reset, t("admin.resetDone"), runSerializedRefresh),
|
openAdminPasswordModal({
|
||||||
);
|
title: t("admin.resetConfirmTitle"),
|
||||||
$("factory-reset").addEventListener("click", () =>
|
body: t("admin.resetConfirmBody"),
|
||||||
adminAction(
|
confirmLabel: t("admin.reset"),
|
||||||
adminApi.factoryReset,
|
onConfirm: async (password, close) => {
|
||||||
t("admin.factoryResetDone"),
|
try {
|
||||||
runSerializedRefresh,
|
await adminApi.reset(password);
|
||||||
),
|
toast(t("admin.resetDone"));
|
||||||
);
|
close();
|
||||||
|
await runSerializedRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$("factory-reset").addEventListener("click", () => {
|
||||||
|
openAdminPasswordModal({
|
||||||
|
title: t("admin.factoryResetConfirmTitle"),
|
||||||
|
body: t("admin.factoryResetConfirmBody"),
|
||||||
|
confirmLabel: t("admin.factoryReset"),
|
||||||
|
onConfirm: async (password, close) => {
|
||||||
|
try {
|
||||||
|
await adminApi.factoryReset(password);
|
||||||
|
toast(t("admin.factoryResetDone"));
|
||||||
|
close();
|
||||||
|
await runSerializedRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupResultsToggle(runSerializedRefresh) {
|
function setupResultsToggle(runSerializedRefresh) {
|
||||||
@@ -145,13 +178,13 @@ function setupPlayerTableActions(runSerializedRefresh) {
|
|||||||
} else if (deleteBtn) {
|
} else if (deleteBtn) {
|
||||||
const playerId = deleteBtn.dataset.deletePlayer;
|
const playerId = deleteBtn.dataset.deletePlayer;
|
||||||
const name = deleteBtn.dataset.name || "";
|
const name = deleteBtn.dataset.name || "";
|
||||||
openConfirmModal({
|
openAdminPasswordModal({
|
||||||
title: t("admin.deleteTitle"),
|
title: t("admin.deleteTitle"),
|
||||||
body: t("admin.deleteBody", { name }),
|
body: t("admin.deleteBody", { name }),
|
||||||
confirmLabel: t("admin.deleteConfirm"),
|
confirmLabel: t("admin.deleteConfirm"),
|
||||||
onConfirm: async (close) => {
|
onConfirm: async (password, close) => {
|
||||||
try {
|
try {
|
||||||
await adminApi.deletePlayer(playerId);
|
await adminApi.deletePlayer(playerId, password);
|
||||||
toast(t("admin.deleteDone"));
|
toast(t("admin.deleteDone"));
|
||||||
close();
|
close();
|
||||||
await runSerializedRefresh();
|
await runSerializedRefresh();
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export function openConfirmModal({
|
|||||||
body,
|
body,
|
||||||
confirmLabel,
|
confirmLabel,
|
||||||
cancelLabel = t("modal.cancel"),
|
cancelLabel = t("modal.cancel"),
|
||||||
|
confirmClass = null,
|
||||||
|
requirePassword = false,
|
||||||
|
passwordLabel = t("auth.password"),
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}) {
|
}) {
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
@@ -48,7 +51,9 @@ export function openConfirmModal({
|
|||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "stack horizontal";
|
actions.className = "stack horizontal";
|
||||||
const confirmBtn = document.createElement("button");
|
const confirmBtn = document.createElement("button");
|
||||||
|
if (confirmClass) confirmBtn.className = confirmClass;
|
||||||
confirmBtn.textContent = confirmLabel ?? t("modal.confirm");
|
confirmBtn.textContent = confirmLabel ?? t("modal.confirm");
|
||||||
|
confirmBtn.disabled = requirePassword;
|
||||||
actions.append(confirmBtn);
|
actions.append(confirmBtn);
|
||||||
if (cancelLabel !== null && cancelLabel !== undefined) {
|
if (cancelLabel !== null && cancelLabel !== undefined) {
|
||||||
const cancelBtn = document.createElement("button");
|
const cancelBtn = document.createElement("button");
|
||||||
@@ -58,7 +63,24 @@ export function openConfirmModal({
|
|||||||
actions.append(cancelBtn);
|
actions.append(cancelBtn);
|
||||||
cancelBtn.addEventListener("click", close);
|
cancelBtn.addEventListener("click", close);
|
||||||
}
|
}
|
||||||
panel.querySelector(".edit-body")?.appendChild(actions);
|
const bodyContainer = panel.querySelector(".edit-body");
|
||||||
|
let passwordInput = null;
|
||||||
|
if (requirePassword && bodyContainer) {
|
||||||
|
const field = document.createElement("label");
|
||||||
|
field.className = "stack";
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "label";
|
||||||
|
label.textContent = passwordLabel;
|
||||||
|
passwordInput = document.createElement("input");
|
||||||
|
passwordInput.type = "password";
|
||||||
|
passwordInput.autocomplete = "current-password";
|
||||||
|
field.append(label, passwordInput);
|
||||||
|
bodyContainer.appendChild(field);
|
||||||
|
passwordInput.addEventListener("input", () => {
|
||||||
|
confirmBtn.disabled = !(passwordInput.value || "").trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bodyContainer?.appendChild(actions);
|
||||||
|
|
||||||
overlay.addEventListener("click", (e) => {
|
overlay.addEventListener("click", (e) => {
|
||||||
if (
|
if (
|
||||||
@@ -70,7 +92,7 @@ export function openConfirmModal({
|
|||||||
});
|
});
|
||||||
confirmBtn.addEventListener("click", async () => {
|
confirmBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await onConfirm?.(close);
|
await onConfirm?.(close, { password: passwordInput?.value ?? "" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user