mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 12:02:51 -04:00
feat(auth): persist admin web sessions
This commit is contained in:
@@ -114,7 +114,8 @@ public class AdminAuthController : ControllerBase
|
|||||||
userName: userName,
|
userName: userName,
|
||||||
isAdministrator: isAdministrator,
|
isAdministrator: isAdministrator,
|
||||||
jellyfinAccessToken: accessToken,
|
jellyfinAccessToken: accessToken,
|
||||||
jellyfinServerId: serverId);
|
jellyfinServerId: serverId,
|
||||||
|
isPersistent: request.RememberMe);
|
||||||
|
|
||||||
SetSessionCookie(session.SessionId, session.ExpiresAtUtc);
|
SetSessionCookie(session.SessionId, session.ExpiresAtUtc);
|
||||||
|
|
||||||
@@ -130,6 +131,7 @@ public class AdminAuthController : ControllerBase
|
|||||||
name = session.UserName,
|
name = session.UserName,
|
||||||
isAdministrator = session.IsAdministrator
|
isAdministrator = session.IsAdministrator
|
||||||
},
|
},
|
||||||
|
rememberMe = session.IsPersistent,
|
||||||
expiresAtUtc = session.ExpiresAtUtc
|
expiresAtUtc = session.ExpiresAtUtc
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -159,6 +161,7 @@ public class AdminAuthController : ControllerBase
|
|||||||
name = session.UserName,
|
name = session.UserName,
|
||||||
isAdministrator = session.IsAdministrator
|
isAdministrator = session.IsAdministrator
|
||||||
},
|
},
|
||||||
|
rememberMe = session.IsPersistent,
|
||||||
expiresAtUtc = session.ExpiresAtUtc
|
expiresAtUtc = session.ExpiresAtUtc
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -196,6 +199,7 @@ public class AdminAuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
public string? Username { get; set; }
|
public string? Username { get; set; }
|
||||||
public string? Password { get; set; }
|
public string? Password { get; set; }
|
||||||
|
public bool RememberMe { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class JellyfinAuthenticateRequest
|
private sealed class JellyfinAuthenticateRequest
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ using allstarr.Services.Lyrics;
|
|||||||
using allstarr.Services.Scrobbling;
|
using allstarr.Services.Scrobbling;
|
||||||
using allstarr.Middleware;
|
using allstarr.Middleware;
|
||||||
using allstarr.Filters;
|
using allstarr.Filters;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.Extensions.Http;
|
using Microsoft.Extensions.Http;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out);
|
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out);
|
||||||
@@ -198,6 +200,11 @@ builder.Services.AddHttpClient(JellyfinProxyService.HttpClientName)
|
|||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
var dataProtectionKeysDirectory = new DirectoryInfo("/app/cache/data-protection");
|
||||||
|
dataProtectionKeysDirectory.Create();
|
||||||
|
builder.Services.AddDataProtection()
|
||||||
|
.PersistKeysToFileSystem(dataProtectionKeysDirectory)
|
||||||
|
.SetApplicationName("allstarr-admin");
|
||||||
|
|
||||||
// Exception handling
|
// Exception handling
|
||||||
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
|
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
namespace allstarr.Services.Admin;
|
namespace allstarr.Services.Admin;
|
||||||
|
|
||||||
@@ -11,27 +14,66 @@ public sealed class AdminAuthSession
|
|||||||
public required bool IsAdministrator { get; init; }
|
public required bool IsAdministrator { get; init; }
|
||||||
public required string JellyfinAccessToken { get; init; }
|
public required string JellyfinAccessToken { get; init; }
|
||||||
public string? JellyfinServerId { get; init; }
|
public string? JellyfinServerId { get; init; }
|
||||||
public required DateTime ExpiresAtUtc { get; init; }
|
public bool IsPersistent { get; init; }
|
||||||
|
public required DateTime ExpiresAtUtc { get; set; }
|
||||||
public DateTime LastSeenUtc { get; set; }
|
public DateTime LastSeenUtc { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-memory authenticated admin sessions for the local Web UI.
|
/// Cookie-backed admin sessions for the local Web UI.
|
||||||
|
/// Session IDs stay in the browser cookie, while the authenticated Jellyfin
|
||||||
|
/// session details are protected and persisted on disk so brief app restarts
|
||||||
|
/// do not force a relogin.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AdminAuthSessionService
|
public class AdminAuthSessionService
|
||||||
{
|
{
|
||||||
public const string SessionCookieName = "allstarr_admin_session";
|
public const string SessionCookieName = "allstarr_admin_session";
|
||||||
public const string HttpContextSessionItemKey = "__allstarr_admin_auth_session";
|
public const string HttpContextSessionItemKey = "__allstarr_admin_auth_session";
|
||||||
|
|
||||||
private static readonly TimeSpan SessionLifetime = TimeSpan.FromHours(12);
|
public static readonly TimeSpan DefaultSessionLifetime = TimeSpan.FromHours(12);
|
||||||
|
public static readonly TimeSpan PersistentSessionLifetime = TimeSpan.FromDays(30);
|
||||||
|
|
||||||
|
private const string SessionStoreFilePath = "/app/cache/admin-auth/sessions.protected";
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, AdminAuthSession> _sessions = new();
|
private readonly ConcurrentDictionary<string, AdminAuthSession> _sessions = new();
|
||||||
|
private readonly IDataProtector _protector;
|
||||||
|
private readonly ILogger<AdminAuthSessionService> _logger;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
private readonly object _persistLock = new();
|
||||||
|
|
||||||
|
public AdminAuthSessionService(
|
||||||
|
IDataProtectionProvider dataProtectionProvider,
|
||||||
|
ILogger<AdminAuthSessionService> logger)
|
||||||
|
{
|
||||||
|
_protector = dataProtectionProvider.CreateProtector("allstarr.admin.auth.sessions.v1");
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
var directory = Path.GetDirectoryName(SessionStoreFilePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadSessionsFromDisk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdminAuthSessionService(ILogger<AdminAuthSessionService> logger)
|
||||||
|
: this(CreateFallbackDataProtectionProvider(), logger)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdminAuthSessionService()
|
||||||
|
: this(CreateFallbackDataProtectionProvider(), NullLogger<AdminAuthSessionService>.Instance)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public AdminAuthSession CreateSession(
|
public AdminAuthSession CreateSession(
|
||||||
string userId,
|
string userId,
|
||||||
string userName,
|
string userName,
|
||||||
bool isAdministrator,
|
bool isAdministrator,
|
||||||
string jellyfinAccessToken,
|
string jellyfinAccessToken,
|
||||||
string? jellyfinServerId)
|
string? jellyfinServerId,
|
||||||
|
bool isPersistent = false)
|
||||||
{
|
{
|
||||||
RemoveExpiredSessions();
|
RemoveExpiredSessions();
|
||||||
|
|
||||||
@@ -44,11 +86,13 @@ public class AdminAuthSessionService
|
|||||||
IsAdministrator = isAdministrator,
|
IsAdministrator = isAdministrator,
|
||||||
JellyfinAccessToken = jellyfinAccessToken,
|
JellyfinAccessToken = jellyfinAccessToken,
|
||||||
JellyfinServerId = jellyfinServerId,
|
JellyfinServerId = jellyfinServerId,
|
||||||
ExpiresAtUtc = now.Add(SessionLifetime),
|
IsPersistent = isPersistent,
|
||||||
|
ExpiresAtUtc = now.Add(isPersistent ? PersistentSessionLifetime : DefaultSessionLifetime),
|
||||||
LastSeenUtc = now
|
LastSeenUtc = now
|
||||||
};
|
};
|
||||||
|
|
||||||
_sessions[session.SessionId] = session;
|
_sessions[session.SessionId] = session;
|
||||||
|
PersistSessions();
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +113,7 @@ public class AdminAuthSessionService
|
|||||||
if (existing.ExpiresAtUtc <= DateTime.UtcNow)
|
if (existing.ExpiresAtUtc <= DateTime.UtcNow)
|
||||||
{
|
{
|
||||||
_sessions.TryRemove(sessionId, out _);
|
_sessions.TryRemove(sessionId, out _);
|
||||||
|
PersistSessions();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,17 +129,117 @@ public class AdminAuthSessionService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_sessions.TryRemove(sessionId, out _);
|
if (_sessions.TryRemove(sessionId, out _))
|
||||||
|
{
|
||||||
|
PersistSessions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveExpiredSessions()
|
private void RemoveExpiredSessions()
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
var removedAny = false;
|
||||||
foreach (var kvp in _sessions)
|
foreach (var kvp in _sessions)
|
||||||
{
|
{
|
||||||
if (kvp.Value.ExpiresAtUtc <= now)
|
if (kvp.Value.ExpiresAtUtc <= now &&
|
||||||
|
_sessions.TryRemove(kvp.Key, out _))
|
||||||
{
|
{
|
||||||
_sessions.TryRemove(kvp.Key, out _);
|
removedAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedAny)
|
||||||
|
{
|
||||||
|
PersistSessions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadSessionsFromDisk()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(SessionStoreFilePath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var protectedPayload = File.ReadAllText(SessionStoreFilePath);
|
||||||
|
if (string.IsNullOrWhiteSpace(protectedPayload))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = _protector.Unprotect(protectedPayload);
|
||||||
|
var sessions = JsonSerializer.Deserialize<List<PersistedAdminAuthSession>>(json, _jsonOptions)
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
foreach (var persisted in sessions)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(persisted.SessionId) ||
|
||||||
|
string.IsNullOrWhiteSpace(persisted.UserId) ||
|
||||||
|
string.IsNullOrWhiteSpace(persisted.UserName) ||
|
||||||
|
string.IsNullOrWhiteSpace(persisted.JellyfinAccessToken) ||
|
||||||
|
persisted.ExpiresAtUtc <= now)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_sessions[persisted.SessionId] = new AdminAuthSession
|
||||||
|
{
|
||||||
|
SessionId = persisted.SessionId,
|
||||||
|
UserId = persisted.UserId,
|
||||||
|
UserName = persisted.UserName,
|
||||||
|
IsAdministrator = persisted.IsAdministrator,
|
||||||
|
JellyfinAccessToken = persisted.JellyfinAccessToken,
|
||||||
|
JellyfinServerId = persisted.JellyfinServerId,
|
||||||
|
IsPersistent = persisted.IsPersistent,
|
||||||
|
ExpiresAtUtc = persisted.ExpiresAtUtc,
|
||||||
|
LastSeenUtc = persisted.LastSeenUtc
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_sessions.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Loaded {Count} persisted admin auth sessions", _sessions.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to load persisted admin auth sessions; starting with an empty session store");
|
||||||
|
_sessions.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PersistSessions()
|
||||||
|
{
|
||||||
|
lock (_persistLock)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var activeSessions = _sessions.Values
|
||||||
|
.Where(session => session.ExpiresAtUtc > DateTime.UtcNow)
|
||||||
|
.Select(session => new PersistedAdminAuthSession
|
||||||
|
{
|
||||||
|
SessionId = session.SessionId,
|
||||||
|
UserId = session.UserId,
|
||||||
|
UserName = session.UserName,
|
||||||
|
IsAdministrator = session.IsAdministrator,
|
||||||
|
JellyfinAccessToken = session.JellyfinAccessToken,
|
||||||
|
JellyfinServerId = session.JellyfinServerId,
|
||||||
|
IsPersistent = session.IsPersistent,
|
||||||
|
ExpiresAtUtc = session.ExpiresAtUtc,
|
||||||
|
LastSeenUtc = session.LastSeenUtc
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(activeSessions, _jsonOptions);
|
||||||
|
var protectedPayload = _protector.Protect(json);
|
||||||
|
File.WriteAllText(SessionStoreFilePath, protectedPayload);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to persist admin auth sessions");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,4 +250,27 @@ public class AdminAuthSessionService
|
|||||||
RandomNumberGenerator.Fill(bytes);
|
RandomNumberGenerator.Fill(bytes);
|
||||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IDataProtectionProvider CreateFallbackDataProtectionProvider()
|
||||||
|
{
|
||||||
|
var keysDirectory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "allstarr-admin-auth-keys"));
|
||||||
|
keysDirectory.Create();
|
||||||
|
return DataProtectionProvider.Create(keysDirectory, configuration =>
|
||||||
|
{
|
||||||
|
configuration.SetApplicationName("allstarr-admin");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PersistedAdminAuthSession
|
||||||
|
{
|
||||||
|
public required string SessionId { get; init; }
|
||||||
|
public required string UserId { get; init; }
|
||||||
|
public required string UserName { get; init; }
|
||||||
|
public required bool IsAdministrator { get; init; }
|
||||||
|
public required string JellyfinAccessToken { get; init; }
|
||||||
|
public string? JellyfinServerId { get; init; }
|
||||||
|
public required bool IsPersistent { get; init; }
|
||||||
|
public required DateTime ExpiresAtUtc { get; init; }
|
||||||
|
public required DateTime LastSeenUtc { get; init; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,12 @@
|
|||||||
<label for="auth-password">Password</label>
|
<label for="auth-password">Password</label>
|
||||||
<input id="auth-password" type="password" required>
|
<input id="auth-password" type="password" required>
|
||||||
|
|
||||||
|
<label class="auth-checkbox" for="auth-remember-me">
|
||||||
|
<input id="auth-remember-me" type="checkbox">
|
||||||
|
<span>Keep me signed in for 30 days on this browser</span>
|
||||||
|
</label>
|
||||||
|
<small class="auth-note">Use only on a device you trust.</small>
|
||||||
|
|
||||||
<button class="primary" type="submit">Sign In</button>
|
<button class="primary" type="submit">Sign In</button>
|
||||||
<div class="auth-error" id="auth-error" role="alert"></div>
|
<div class="auth-error" id="auth-error" role="alert"></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -951,6 +957,7 @@
|
|||||||
<div class="card-actions-row">
|
<div class="card-actions-row">
|
||||||
<button class="primary" type="button" id="open-github-issue-btn">Open Bug Report on GitHub</button>
|
<button class="primary" type="button" id="open-github-issue-btn">Open Bug Report on GitHub</button>
|
||||||
<button type="button" id="copy-issue-report-btn">Copy Report</button>
|
<button type="button" id="copy-issue-report-btn">Copy Report</button>
|
||||||
|
<button type="button" id="clear-issue-report-btn">Clear Report</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ export async function fetchAdminSession() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginAdminSession(username, password) {
|
export async function loginAdminSession(username, password, rememberMe = false) {
|
||||||
return requestJson(
|
return requestJson(
|
||||||
"/api/admin/auth/login",
|
"/api/admin/auth/login",
|
||||||
asJsonBody({ username, password }),
|
asJsonBody({ username, password, rememberMe }),
|
||||||
"Authentication failed",
|
"Authentication failed",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,9 +197,11 @@ function wireLoginForm() {
|
|||||||
|
|
||||||
const usernameInput = document.getElementById("auth-username");
|
const usernameInput = document.getElementById("auth-username");
|
||||||
const passwordInput = document.getElementById("auth-password");
|
const passwordInput = document.getElementById("auth-password");
|
||||||
|
const rememberMeInput = document.getElementById("auth-remember-me");
|
||||||
const authError = document.getElementById("auth-error");
|
const authError = document.getElementById("auth-error");
|
||||||
const username = usernameInput?.value?.trim() || "";
|
const username = usernameInput?.value?.trim() || "";
|
||||||
const password = passwordInput?.value || "";
|
const password = passwordInput?.value || "";
|
||||||
|
const rememberMe = Boolean(rememberMeInput?.checked);
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
if (authError) {
|
if (authError) {
|
||||||
@@ -213,7 +215,7 @@ function wireLoginForm() {
|
|||||||
authError.textContent = "";
|
authError.textContent = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await API.loginAdminSession(username, password);
|
const result = await API.loginAdminSession(username, password, rememberMe);
|
||||||
if (passwordInput) {
|
if (passwordInput) {
|
||||||
passwordInput.value = "";
|
passwordInput.value = "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,36 @@ async function copyIssueReport({ silent = false } = {}) {
|
|||||||
return copied;
|
return copied;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearIssueReport() {
|
||||||
|
const titleInput = getElement("issue-report-title");
|
||||||
|
const primaryInput = getElement("issue-report-primary");
|
||||||
|
const secondaryInput = getElement("issue-report-secondary");
|
||||||
|
const tertiaryInput = getElement("issue-report-tertiary");
|
||||||
|
const contextInput = getElement("issue-report-context");
|
||||||
|
|
||||||
|
const hasDraft = [
|
||||||
|
titleInput?.value,
|
||||||
|
primaryInput?.value,
|
||||||
|
secondaryInput?.value,
|
||||||
|
tertiaryInput?.value,
|
||||||
|
contextInput?.value,
|
||||||
|
].some((value) => String(value ?? "").trim().length > 0);
|
||||||
|
|
||||||
|
if (hasDraft && !window.confirm("Clear the current report draft?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (titleInput) titleInput.value = "";
|
||||||
|
if (primaryInput) primaryInput.value = "";
|
||||||
|
if (secondaryInput) secondaryInput.value = "";
|
||||||
|
if (tertiaryInput) tertiaryInput.value = "";
|
||||||
|
if (contextInput) contextInput.value = "";
|
||||||
|
|
||||||
|
refreshIssueReportPreview();
|
||||||
|
titleInput?.focus();
|
||||||
|
showToast("Report draft cleared", "success", 2500);
|
||||||
|
}
|
||||||
|
|
||||||
function validateTitle() {
|
function validateTitle() {
|
||||||
const titleInput = getElement("issue-report-title");
|
const titleInput = getElement("issue-report-title");
|
||||||
if (!titleInput?.value?.trim()) {
|
if (!titleInput?.value?.trim()) {
|
||||||
@@ -411,6 +441,7 @@ export function initIssueReporter() {
|
|||||||
const tertiaryInput = getElement("issue-report-tertiary");
|
const tertiaryInput = getElement("issue-report-tertiary");
|
||||||
const contextInput = getElement("issue-report-context");
|
const contextInput = getElement("issue-report-context");
|
||||||
const copyButton = getElement("copy-issue-report-btn");
|
const copyButton = getElement("copy-issue-report-btn");
|
||||||
|
const clearButton = getElement("clear-issue-report-btn");
|
||||||
const openButton = getElement("open-github-issue-btn");
|
const openButton = getElement("open-github-issue-btn");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -421,6 +452,7 @@ export function initIssueReporter() {
|
|||||||
!tertiaryInput ||
|
!tertiaryInput ||
|
||||||
!contextInput ||
|
!contextInput ||
|
||||||
!copyButton ||
|
!copyButton ||
|
||||||
|
!clearButton ||
|
||||||
!openButton
|
!openButton
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -436,6 +468,9 @@ export function initIssueReporter() {
|
|||||||
copyButton.addEventListener("click", () => {
|
copyButton.addEventListener("click", () => {
|
||||||
copyIssueReport();
|
copyIssueReport();
|
||||||
});
|
});
|
||||||
|
clearButton.addEventListener("click", () => {
|
||||||
|
clearIssueReport();
|
||||||
|
});
|
||||||
openButton.addEventListener("click", () => {
|
openButton.addEventListener("click", () => {
|
||||||
openGithubIssueDraft();
|
openGithubIssueDraft();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,6 +58,26 @@ body {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-checkbox input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-note {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-card label {
|
.auth-card label {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user