mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
The account page lists the account's reports as a table and the Client System Info page lists every report (or each account's newest), sortable and searchable by account name or video card; a row opens every field. Values that say little about the real system are marked, and every caveat is a tooltip on the value and column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
732 lines
39 KiB
Django/Jinja
732 lines
39 KiB
Django/Jinja
{% extends "base.jinja2" %}
|
|
|
|
{% block title %}Account #{{ account.id }} - DarkflameServer{% endblock %}
|
|
|
|
{% block content %}
|
|
<nav data-crumbs data-crumb-label="{{ account.name }}" aria-label="Breadcrumb"><ol class="breadcrumb mb-3">{% if can.accounts_view %}<li class="breadcrumb-item"><a href="/accounts">Accounts</a></li>{% endif %}<li class="breadcrumb-item active" aria-current="page">{{ account.name }}</li></ol></nav>
|
|
|
|
<div class="card mb-4" id="account" data-account-id="{{ account.id }}" data-gm-level="{{ account.gm_level }}" data-email="{{ account.email }}" data-account-name="{{ account.name }}" data-self="{% if is_self %}1{% endif %}" data-can-moderate="{% if manage.moderation %}1{% endif %}">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h4 class="mb-0">{{ account.name }}</h4>
|
|
<span class="text-body-secondary">Account #{{ account.id }}</span>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row g-4 mb-4">
|
|
<div class="col-md-4 col-sm-6">
|
|
<div class="text-body-secondary small text-uppercase">Account ID</div>
|
|
<div>{{ account.id }}</div>
|
|
</div>
|
|
<div class="col-md-4 col-sm-6">
|
|
<div class="text-body-secondary small text-uppercase">Username</div>
|
|
<div>{{ account.name }}</div>
|
|
</div>
|
|
<div class="col-md-4 col-sm-6">
|
|
<div class="text-body-secondary small text-uppercase">Created</div>
|
|
<div>{{ account.created_at }}</div>
|
|
</div>
|
|
<div class="col-md-4 col-sm-6">
|
|
<div class="text-body-secondary small text-uppercase">GM Level</div>
|
|
<div>
|
|
{% if account.gm_level > 0 %}
|
|
<span class="badge text-bg-info">GM {{ account.gm_level }}</span>
|
|
{% else %}
|
|
<span class="badge text-bg-secondary">Civilian</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4 col-sm-6">
|
|
<div class="text-body-secondary small text-uppercase">Ban Status</div>
|
|
<div>
|
|
{% if account.banned %}
|
|
<span class="badge text-bg-danger">Banned</span>
|
|
<span class="small" id="banDetails" data-expires="{{ account.ban_expires }}"></span>
|
|
{% if account.ban_reason != "" %}<div class="small text-body-secondary">{{ account.ban_reason }}</div>{% endif %}
|
|
{% else %}
|
|
<span class="badge text-bg-success">Active</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4 col-sm-6">
|
|
<div class="text-body-secondary small text-uppercase">Lock Status</div>
|
|
<div>
|
|
{% if account.locked %}
|
|
<span class="badge text-bg-warning">Locked</span>
|
|
{% else %}
|
|
<span class="badge text-bg-success">Unlocked</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4 col-sm-6">
|
|
<div class="text-body-secondary small text-uppercase">Email</div>
|
|
<div>
|
|
{% if account.email != "" %}
|
|
{{ account.email }}
|
|
{% if account.email_confirmed %}<span class="badge text-bg-success">Confirmed</span>{% else %}<span class="badge text-bg-warning">Unconfirmed</span>{% endif %}
|
|
{% else %}
|
|
<span class="text-body-secondary">None</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% if can.accounts_view %}
|
|
<div class="col-md-4 col-sm-6">
|
|
<div class="text-body-secondary small text-uppercase">Play Key</div>
|
|
<div>
|
|
{% if account.play_key %}
|
|
{% if account.play_key.key_string != "" %}<a href="/play_keys/{{ account.play_key.id }}"><code>{{ account.play_key.key_string }}</code></a>{% else %}Used a play key{% endif %}
|
|
{% else %}
|
|
<span class="text-body-secondary">None</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
<div class="col-md-4 col-sm-6">
|
|
<div class="text-body-secondary small text-uppercase">Mute</div>
|
|
<div id="muteDisplay">
|
|
{% if account.mute_expire > 0 %}
|
|
<span class="badge text-bg-danger" data-ts="{{ account.mute_expire }}">Muted</span>
|
|
{% else %}
|
|
Not muted
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if manage.tools or manage.moderation %}
|
|
<div class="d-flex gap-2 flex-wrap">
|
|
{% if manage.moderation %}
|
|
{% if can.accounts_ban %}
|
|
{% if not account.banned %}
|
|
<button class="btn btn-danger btn-sm" onclick="toggleBan(true)">Ban</button>
|
|
{% else %}
|
|
<button class="btn btn-success btn-sm" onclick="toggleBan(false)">Unban</button>
|
|
{% endif %}
|
|
{% if not account.locked %}
|
|
<button class="btn btn-warning btn-sm" onclick="toggleLock(true)">Lock</button>
|
|
{% else %}
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="toggleLock(false)">Unlock</button>
|
|
{% endif %}
|
|
{% endif %}
|
|
{% if can.accounts_mute %}
|
|
<button class="btn btn-outline-warning btn-sm" data-bs-toggle="modal" data-bs-target="#muteModal">Mute</button>
|
|
{% endif %}
|
|
{% endif %}
|
|
{% if manage.tools %}
|
|
{% if can.accounts_kick %}
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="kickAccount()">Kick</button>
|
|
{% endif %}
|
|
{% if can.accounts_send_reset and email_enabled and account.email_confirmed %}
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="sendPasswordReset()">Email Reset Link</button>
|
|
{% endif %}
|
|
{% endif %}
|
|
{% if manage.moderation %}
|
|
{% if can.accounts_gm_level %}
|
|
<button class="btn btn-outline-info btn-sm" data-bs-toggle="modal" data-bs-target="#gmLevelModal">Set GM Level</button>
|
|
{% endif %}
|
|
{% if can.accounts_manage %}
|
|
{% if email_enabled %}
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="setAccountEmail()">Set Email</button>
|
|
{% endif %}
|
|
<button class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#passwordModal">Reset Password</button>
|
|
{% if account.two_factor %}
|
|
<button class="btn btn-outline-secondary btn-sm" id="reset2faBtn">Reset 2FA</button>
|
|
{% endif %}
|
|
{% endif %}
|
|
{% endif %}
|
|
{% if manage.tools and can.accounts_manage %}
|
|
<button class="btn btn-outline-secondary btn-sm" id="revokeSessionsBtn">Sign Out Everywhere</button>
|
|
{% endif %}
|
|
{% if manage.moderation and can.accounts_delete %}
|
|
<button class="btn btn-outline-danger btn-sm" onclick="deleteAccount()">Delete Account</button>
|
|
{% endif %}
|
|
</div>
|
|
{% else if is_self and (can.accounts_kick or can.accounts_mute or can.accounts_ban) %}
|
|
<p class="text-body-secondary small mb-0">Your GM level may not use staff tools on your own account (the self_tools and self_moderation permissions).</p>
|
|
{% else if not is_self and (can.accounts_kick or can.accounts_mute or can.accounts_ban) %}
|
|
<p class="text-body-secondary small mb-0">{% if account.gm_level > gmLevel %}You cannot manage accounts with a higher GM level than your own.{% else %}You cannot manage accounts with the same GM level as your own (the manage_equal_rank permission).{% endif %}</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mb-4" id="relatedCard" data-kind="account" data-id="{{ account.id }}"><div class="card-body text-body-secondary">Loading related data…</div></div>
|
|
|
|
{% if can.accounts_links %}
|
|
<div class="card mb-4" id="linksCard" data-account="{{ account.id }}">
|
|
<div class="card-header"><h5 class="mb-0">Linked accounts <span class="badge rounded-pill text-bg-secondary align-middle" id="linkCount"></span></h5></div>
|
|
<div class="card-body pb-0"><p class="small text-body-secondary mb-2"><span id="linkNote"></span> A shared address can also be a family, school or public network.</p></div>
|
|
<ul class="list-group list-group-flush" id="linkList"><li class="list-group-item text-body-secondary">Loading…</li></ul>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if can.client_sysinfo %}
|
|
<div class="card mb-4" id="sysinfoCard" data-account="{{ account.id }}">
|
|
<div class="card-header"><h5 class="mb-0">Client system info (as reported) <span class="badge rounded-pill text-bg-secondary align-middle" id="sysinfoCount"></span></h5></div>
|
|
<div class="card-body">
|
|
<p class="small text-body-secondary mb-2">What this account's game client said about its system at each login, exactly as sent. The client asks old Windows
|
|
functions from a 32-bit program, so several values are compatibility defaults, not the player's real hardware; each field says how far it can be trusted.
|
|
A new entry starts when anything but the memory in use changes.{% if not can.logs_audit %} Addresses are only shown with logs_audit.{% endif %}</p>
|
|
<p class="small text-body-secondary mb-2">Dotted values have a tooltip saying what they're worth; ⚠ marks values that say little or nothing about the
|
|
player's real system. Click a row for every field.</p>
|
|
<table id="sysinfoTable" class="table table-sm table-hover table-stack align-middle w-100">
|
|
<thead><tr><th>Last seen</th><th>Account</th><th class="text-end">Logins</th><th data-caveat="os">Windows reported</th><th data-caveat="video">Video card</th>
|
|
<th class="text-end" data-caveat="cpus">CPUs</th><th class="text-end" data-caveat="memory">Memory</th><th data-caveat="client">Client build</th><th>First seen</th></tr></thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if can.accounts_notes %}
|
|
<div class="card mb-4" id="historyCard">
|
|
<div class="card-header d-flex justify-content-between align-items-center"><h5 class="mb-0">Moderation history</h5><span class="small text-body-secondary" id="warningCount"></span></div>
|
|
<div class="card-body">
|
|
<form class="row g-2 align-items-end mb-3" id="noteForm">
|
|
<div class="col-sm-2"><label class="form-label small" for="noteKind">Type</label>
|
|
<select class="form-select form-select-sm" id="noteKind"><option value="note">Note</option><option value="warning">Warning</option></select></div>
|
|
<div class="col-sm-7"><label class="form-label small" for="noteText">Text</label><input class="form-control form-control-sm" id="noteText" maxlength="300" required placeholder="What happened"></div>
|
|
<div class="col-sm-3"><button class="btn btn-sm btn-primary w-100">Add</button></div>
|
|
<div class="col-12 d-none" id="tellPlayerWrap"><div class="form-check small"><input class="form-check-input" type="checkbox" id="tellPlayer" checked><label class="form-check-label" for="tellPlayer">Also show the warning to the player in game if they are online</label></div></div>
|
|
</form>
|
|
<ol class="list-unstyled mb-0" id="noteList"><li class="text-body-secondary">Loading…</li></ol>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% if can.accounts_notes or (is_self and can.own_strikes) %}
|
|
<div class="card mb-4" id="strikesCard" data-account="{{ account.id }}" data-self="{% if is_self %}1{% endif %}" data-can-manage="{% if can_manage %}1{% endif %}">
|
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
|
<h5 class="mb-0">Strikes <span class="badge rounded-pill text-bg-secondary align-middle" id="strikeCount"></span></h5>
|
|
{% if can.strikes_give and can_manage %}<button type="button" class="btn btn-sm btn-outline-danger" id="giveStrike">Give a strike</button>{% endif %}
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="small text-body-secondary" id="strikeHelp"></p>
|
|
<ol class="list-unstyled mb-0" id="strikeList"><li class="text-body-secondary">Loading…</li></ol>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% if is_self %}
|
|
{% if email_enabled %}
|
|
<div class="row g-4 mb-4">
|
|
<div class="col-lg-6">
|
|
<div class="card h-100">
|
|
<div class="card-header"><h5 class="mb-0">Email</h5></div>
|
|
<div class="card-body">
|
|
<p class="small text-body-secondary">Used to reset your password if you forget it. Changing it sends a confirmation link to the new address.</p>
|
|
<div class="mb-2"><input type="email" class="form-control" id="ownEmail" placeholder="you@example.com" maxlength="254" autocomplete="email"></div>
|
|
<div class="mb-2"><input type="password" class="form-control" id="emailPwd" placeholder="Current password" maxlength="40" autocomplete="current-password"></div>
|
|
<button class="btn btn-primary btn-sm" onclick="saveOwnEmail()">Save Email</button>
|
|
{% if account.email != "" and not account.email_confirmed %}
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="resendVerification()">Resend Confirmation</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% if can.email_settings %}
|
|
<div class="row g-4 mb-4">
|
|
<div class="col-lg-8">
|
|
<div class="card h-100">
|
|
<div class="card-header"><h5 class="mb-0">Email Settings</h5></div>
|
|
<div class="card-body">
|
|
<dl class="row small mb-2" id="emailStatus"><dt class="col-sm-4">Status</dt><dd class="col-sm-8">Loading…</dd></dl>
|
|
<div id="emailError" class="alert alert-danger small d-none"></div>
|
|
<div id="oauthControls" class="mb-3 d-none">
|
|
<button class="btn btn-sm btn-primary" id="oauthConnect" onclick="connectMailAccount()">Connect mail account</button>
|
|
<button class="btn btn-sm btn-outline-danger" id="oauthDisconnect" onclick="disconnectMailAccount()">Disconnect</button>
|
|
<div class="form-text">Register this redirect URI with your provider: <code id="oauthRedirect"></code></div>
|
|
</div>
|
|
<div class="input-group input-group-sm">
|
|
<input type="email" class="form-control" id="testEmailTo" placeholder="Send a test email to">
|
|
<button class="btn btn-outline-primary" onclick="sendTestEmail()">Send Test</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
<div class="row g-4 mb-4">
|
|
<div class="col-lg-6">
|
|
<div class="card h-100" id="two-factor">
|
|
<div class="card-header d-flex justify-content-between align-items-center"><h5 class="mb-0">Two-factor login</h5><span id="twoFactorBadge"></span></div>
|
|
<div class="card-body">
|
|
<div id="twoFactorRequired" class="alert alert-warning small d-none">Your GM level requires two-factor login. Set it up to use the rest of the dashboard.</div>
|
|
<div id="twoFactorOff" class="d-none">
|
|
<p class="small text-body-secondary">After your password, also ask for a code from an authenticator app (Google Authenticator, Authy, 1Password, Bitwarden...). A stolen password alone can't get in.</p>
|
|
<button class="btn btn-primary btn-sm" id="twoFactorStart">Set up</button>
|
|
</div>
|
|
<div id="twoFactorSetup" class="d-none">
|
|
<ol class="small ps-3">
|
|
<li>Scan this with your authenticator app, or enter the key by hand.</li>
|
|
<li>Type the 6-digit code it shows.</li>
|
|
</ol>
|
|
<div class="d-flex flex-wrap gap-3 align-items-start mb-3">
|
|
<div id="twoFactorQr" class="qr-box" aria-label="QR code for your authenticator app"></div>
|
|
<div class="small"><div class="text-body-secondary">Key</div><code id="twoFactorSecret" class="user-select-all"></code></div>
|
|
</div>
|
|
<div class="input-group input-group-sm mb-2">
|
|
<input type="text" class="form-control" id="twoFactorEnableCode" placeholder="6-digit code" inputmode="numeric" autocomplete="one-time-code" maxlength="8">
|
|
<button class="btn btn-primary" id="twoFactorEnable">Turn on</button>
|
|
</div>
|
|
</div>
|
|
<div id="twoFactorOn" class="d-none">
|
|
<p class="small mb-2" id="twoFactorInfo"></p>
|
|
<div class="input-group input-group-sm mb-2">
|
|
<input type="text" class="form-control" id="twoFactorManageCode" placeholder="Current code" inputmode="numeric" autocomplete="one-time-code" maxlength="16">
|
|
<button class="btn btn-outline-secondary" id="twoFactorNewCodes">New recovery codes</button>
|
|
</div>
|
|
<div class="input-group input-group-sm" id="twoFactorDisableGroup">
|
|
<input type="password" class="form-control" id="twoFactorDisablePwd" placeholder="Password" maxlength="40" autocomplete="current-password">
|
|
<button class="btn btn-outline-danger" id="twoFactorDisable">Turn off</button>
|
|
</div>
|
|
</div>
|
|
<div id="recoveryCodes" class="alert alert-info small mt-3 d-none">
|
|
<div class="fw-semibold mb-1">Recovery codes: save these now, they are shown only once.</div>
|
|
<div class="mb-2">Each one works once, instead of a code, if you lose your phone.</div>
|
|
<pre class="mb-2 user-select-all" id="recoveryCodeList"></pre>
|
|
<button class="btn btn-sm btn-outline-primary" id="recoveryCopy">Copy</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% if can.own_history %}
|
|
<div class="col-lg-6">
|
|
<div class="card h-100">
|
|
<div class="card-header"><h5 class="mb-0">Trade & mail history</h5></div>
|
|
<div class="card-body">
|
|
<p class="small text-body-secondary">Items and coins your characters traded or mailed.</p>
|
|
<table id="ownTransfers" class="table table-sm table-striped w-100">
|
|
<thead><tr><th>Time</th><th>How</th><th>What</th><th>From</th><th>To</th></tr></thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="row g-4 mb-4">
|
|
<div class="col-lg-6">
|
|
<div class="card h-100">
|
|
<div class="card-header"><h5 class="mb-0">Change Password</h5></div>
|
|
<div class="card-body">
|
|
<div class="mb-2"><input type="password" class="form-control" id="currentPwd" placeholder="Current password" maxlength="40" autocomplete="current-password"></div>
|
|
<div class="mb-2"><input type="password" class="form-control" id="ownNewPwd" placeholder="New password (6-40 characters)" minlength="6" maxlength="40" autocomplete="new-password"></div>
|
|
<button class="btn btn-primary btn-sm" onclick="changeOwnPassword()">Change Password</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-6">
|
|
<div class="card h-100">
|
|
<div class="card-header"><h5 class="mb-0">Sessions</h5></div>
|
|
<div class="card-body">
|
|
<p class="small text-body-secondary mb-2">Signed in on a computer that isn't yours, or lost an API key? This signs out every other browser and stops every API key you made (rotate a key to start it again).</p>
|
|
<button class="btn btn-outline-danger btn-sm" id="revokeOwnSessions">Sign out everywhere else</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if is_self or can.grants_manage %}
|
|
<div class="card mb-4">
|
|
<div class="card-header"><h5 class="mb-0">Permission grants</h5></div>
|
|
<div class="card-body">
|
|
<p class="small text-body-secondary">Dashboard permissions and in-game commands given to{% if can.grants_manage %} (or taken from){% endif %} this account on top of what its GM level allows. They count on the dashboard and in game, on every character; each character can have its own too (on its page).</p>
|
|
<div id="grantsPanel" data-account="{{ account.id }}"></div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if is_self or (can.api_keys_manage and can.accounts_view and manage.tools) %}
|
|
<div class="card mb-4" id="apiKeys" data-account="{{ account.id }}" data-self="{% if is_self %}1{% endif %}" data-can-create="{% if is_self and can.api_access %}1{% endif %}">
|
|
<div class="card-header d-flex align-items-center">
|
|
<h5 class="mb-0 me-auto">API Keys</h5>
|
|
{% if is_self and can.api_access %}<button class="btn btn-sm btn-primary" id="apiKeyNew">New key</button>{% endif %}
|
|
</div>
|
|
<div class="card-body">
|
|
{% if is_self %}
|
|
<p class="small text-body-secondary">Keys for scripts and bots (<code>Authorization: Bearer <key></code>, see the <a href="/api_docs">API docs</a>). A key does only what you pick for it, and never more than you can do right now: if your GM level or permissions change, your keys change with them.{% if not can.api_access %} API access isn't turned on for your account, so you can't make keys.{% endif %}</p>
|
|
{% endif %}
|
|
<div id="apiKeyCreated" class="alert alert-success d-none">
|
|
<div class="mb-1"><strong>Copy this key now.</strong> It won't be shown again.</div>
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" class="form-control font-monospace" id="apiKeySecret" readonly>
|
|
<button class="btn btn-outline-secondary" type="button" id="apiKeyCopy">Copy</button>
|
|
</div>
|
|
</div>
|
|
<form id="apiKeyForm" class="border rounded p-3 mb-3 d-none">
|
|
<div class="row g-2">
|
|
<div class="col-md-4"><label class="form-label small mb-0" for="apiKeyName">Name</label><input class="form-control form-control-sm" id="apiKeyName" maxlength="32" required placeholder="Discord bot"></div>
|
|
<div class="col-md-8"><label class="form-label small mb-0" for="apiKeyNote">Note</label><input class="form-control form-control-sm" id="apiKeyNote" maxlength="255" placeholder="What it's for (optional)"></div>
|
|
<div class="col-6 col-md-3"><label class="form-label small mb-0" for="apiKeyExpiry">Expires</label>
|
|
<select class="form-select form-select-sm" id="apiKeyExpiry"><option value="7">In 7 days</option><option value="30">In 30 days</option><option value="90" selected>In 90 days</option><option value="365">In a year</option><option value="0">Never</option></select></div>
|
|
<div class="col-6 col-md-3"><label class="form-label small mb-0" for="apiKeyRate">Requests a minute</label><input type="number" min="0" class="form-control form-control-sm" id="apiKeyRate" placeholder="Default"></div>
|
|
<div class="col-6 col-md-3"><label class="form-label small mb-0" for="apiKeyQuota">Requests a day</label><input type="number" min="0" class="form-control form-control-sm" id="apiKeyQuota" placeholder="No limit"></div>
|
|
<div class="col-6 col-md-3 d-flex align-items-end"><div class="form-check"><input class="form-check-input" type="checkbox" id="apiKeyReadOnly"><label class="form-check-label small" for="apiKeyReadOnly">Read-only (no changes)</label></div></div>
|
|
<div class="col-md-6"><label class="form-label small mb-0" for="apiKeyIps">Only from addresses</label><input class="form-control form-control-sm font-monospace" id="apiKeyIps" placeholder="Any; e.g. 203.0.113.7, 10.0.0."></div>
|
|
<div class="col-md-6"><label class="form-label small mb-0" for="apiKeyPaths">Only these paths</label><input class="form-control form-control-sm font-monospace" id="apiKeyPaths" placeholder="Any; e.g. /api/chat, /api/players"></div>
|
|
</div>
|
|
<div class="mt-3">
|
|
<div class="form-check form-check-inline"><input class="form-check-input" type="radio" name="apiKeyScope" id="apiKeyScopeSome" value="some" checked><label class="form-check-label" for="apiKeyScopeSome">Only the permissions I pick</label></div>
|
|
<div class="form-check form-check-inline"><input class="form-check-input" type="radio" name="apiKeyScope" id="apiKeyScopeAll" value="all"><label class="form-check-label" for="apiKeyScopeAll">All of my permissions</label></div>
|
|
</div>
|
|
<div id="apiKeyPicker" class="mt-2 small"></div>
|
|
<div class="mt-3"><button class="btn btn-sm btn-primary" type="submit">Make key</button> <button class="btn btn-sm btn-outline-secondary" type="button" id="apiKeyCancel">Cancel</button></div>
|
|
</form>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle mb-0">
|
|
<thead><tr><th>Name</th><th>Can do</th><th>Limits</th><th>Last used</th><th>Requests</th><th>Expires</th><th>Status</th><th></th></tr></thead>
|
|
<tbody id="apiKeyRows"><tr><td colspan="8" class="text-body-secondary">Loading...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="modal fade" id="gmLevelModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Set GM Level</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<select id="gmLevelSelect" class="form-select">
|
|
{% for level in labels.gmLevels %}<option value="{{ level.value }}">{{ level.value }} - {{ level.name }}</option>{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="setGmLevel()">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="banModal" tabindex="-1" aria-labelledby="banTitle" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<form class="modal-content" id="banForm">
|
|
<div class="modal-header"><h5 class="modal-title" id="banTitle">Ban account</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
|
|
<div class="modal-body">
|
|
<div class="mb-2"><label class="form-label" for="banDays">For</label>
|
|
<select id="banDays" class="form-select"><option value="0">Permanently</option><option value="1">1 day</option><option value="3">3 days</option><option value="7">1 week</option><option value="14">2 weeks</option><option value="30">30 days</option><option value="90">90 days</option></select></div>
|
|
<div><label class="form-label" for="banReason">Reason</label><input id="banReason" class="form-control" maxlength="300" placeholder="Shown to the player when they try to log in">
|
|
<div class="form-text">Also saved in the account's moderation history. A timed ban ends by itself.</div></div>
|
|
</div>
|
|
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button><button type="submit" class="btn btn-danger">Ban</button></div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<div class="modal fade" id="muteModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Mute Account</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<label for="muteDuration" class="form-label">Duration</label>
|
|
<select id="muteDuration" class="form-select">
|
|
<option value="0">Unmute</option>
|
|
<option value="1">1 Day</option>
|
|
<option value="7">1 Week</option>
|
|
<option value="30">30 Days</option>
|
|
<option value="365">1 Year</option>
|
|
<option value="36500">Permanent</option>
|
|
</select>
|
|
<label for="muteReason" class="form-label mt-2">Reason</label>
|
|
<input id="muteReason" class="form-control" maxlength="300" placeholder="Saved in the moderation history">
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-warning" onclick="setMute()">Apply</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="passwordModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Reset Password</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<label for="newPwd" class="form-label">New Password</label>
|
|
<input type="password" class="form-control" id="newPwd" minlength="6" maxlength="40" required>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="resetPassword()">Reset</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if existsIn(account, "characters") %}
|
|
<div class="card mt-4">
|
|
<div class="card-header"><h5 class="mb-0">Characters</h5></div>
|
|
<div class="card-body">
|
|
{% if length(account.characters) == 0 %}
|
|
<p class="text-body-secondary mb-0">No characters</p>
|
|
{% else %}
|
|
<div class="table-responsive">
|
|
<table class="table table-striped table-hover mb-0">
|
|
<thead><tr><th>Name</th><th>Last Login</th><th></th></tr></thead>
|
|
<tbody>
|
|
{% for char in account.characters %}
|
|
<tr>
|
|
<td>{{ char.name }}</td>
|
|
<td class="char-login" data-ts="{{ char.last_login }}"></td>
|
|
<td>{% if can.characters_view or (is_self and can.own_characters) %}<a href="/characters/{{ char.id }}" class="btn btn-sm btn-outline-primary">View</a>{% endif %}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
{% if is_self %}
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcode-generator/1.4.4/qrcode.min.js" integrity="sha384-mZT2gIty7ZDdOGkxfP6joZcYdMW1Jvj9dRlfpTmaJAKKXTqzygtB22k7FLe+KZC1" crossorigin="anonymous"></script>
|
|
<script src="/js/account-security.js"></script>
|
|
{% endif %}
|
|
<script>
|
|
var accountEl = document.getElementById('account');
|
|
var accountId = accountEl.getAttribute('data-account-id');
|
|
var base = '/api/accounts/' + accountId;
|
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
loadEmailStatus();
|
|
var ownEmail = document.getElementById('ownEmail');
|
|
if (ownEmail) ownEmail.value = accountEl.getAttribute('data-email') || '';
|
|
showServerValues();
|
|
// Update in place when someone else changes this account
|
|
Live.refreshPage('accounts', accountId);
|
|
});
|
|
// After an in-place update (nav.js) the server's values may have changed: show them the same way again
|
|
document.addEventListener('dash:refreshed', showServerValues);
|
|
|
|
// The GM level, mute and last logins as the server has them, shown in the browser's time
|
|
function showServerValues() {
|
|
var gmSelect = document.getElementById('gmLevelSelect');
|
|
// Not while someone is picking a new level
|
|
if (gmSelect && (!gmSelect.dataset.shown || gmSelect.value === gmSelect.dataset.shown)) {
|
|
gmSelect.value = gmSelect.dataset.shown = accountEl.getAttribute('data-gm-level');
|
|
// Only operators may grant their own level or higher
|
|
Array.prototype.forEach.call(gmSelect.options, function (opt) {
|
|
if (DASH.gmLevel < 9 && parseInt(opt.value, 10) >= DASH.gmLevel) opt.disabled = true;
|
|
});
|
|
}
|
|
var muteEl = document.querySelector('#muteDisplay [data-ts]');
|
|
if (muteEl) {
|
|
var ts = parseInt(muteEl.getAttribute('data-ts'), 10);
|
|
if (ts > Date.now() / 1000) muteEl.textContent = 'Muted until ' + fmt.unix(ts);
|
|
else { muteEl.className = 'badge text-bg-secondary'; muteEl.textContent = 'Expired'; }
|
|
}
|
|
document.querySelectorAll('.char-login').forEach(function (el) {
|
|
var ts = parseInt(el.getAttribute('data-ts'), 10);
|
|
el.textContent = ts > 0 ? fmt.unix(ts) : 'Never';
|
|
});
|
|
}
|
|
|
|
// Show the page again once the change (and any live action it started, like a kick) has finished
|
|
function reloadOnSuccess(promise) {
|
|
promise.then(function (d) { return d.result || d; }).then(function () { reloadInPlace(); }).catch(function () {});
|
|
}
|
|
|
|
var reset2faBtn = document.getElementById('reset2faBtn');
|
|
if (reset2faBtn) reset2faBtn.addEventListener('click', function () {
|
|
if (!confirm('Turn off two-factor login for this account? Do this only after confirming who is asking (lost phone). They will be signed out everywhere.')) return;
|
|
api.action('/api/accounts/' + accountId + '/2fa/reset').then(function (r) { toast(r.message, 'success'); }).catch(function () {});
|
|
});
|
|
|
|
var revokeSessionsBtn = document.getElementById('revokeSessionsBtn');
|
|
if (revokeSessionsBtn) revokeSessionsBtn.addEventListener('click', function () {
|
|
if (!confirm('Sign this account out of every dashboard session and API token?')) return;
|
|
api.action('/api/accounts/' + accountId + '/sessions/revoke').then(function (r) { toast(r.message, 'success'); }).catch(function () {});
|
|
});
|
|
|
|
var revokeOwnSessions = document.getElementById('revokeOwnSessions');
|
|
if (revokeOwnSessions) revokeOwnSessions.addEventListener('click', function () {
|
|
if (!confirm('Sign out every other browser and stop all your API tokens?')) return;
|
|
api.action('/api/account/sessions/revoke').then(function (r) { toast(r.message, 'success'); }).catch(function () {});
|
|
});
|
|
|
|
function kickAccount() {
|
|
if (!confirm('Disconnect every online session of this account?')) return;
|
|
api.action(base + '/kick', {}).catch(function () {});
|
|
}
|
|
|
|
function toggleBan(banned) {
|
|
if (banned) { bootstrap.Modal.getOrCreateInstance(document.getElementById('banModal')).show(); return; }
|
|
if (!confirm('Unban this account?')) return;
|
|
reloadOnSuccess(api.action(base + '/ban', { banned: false }));
|
|
}
|
|
document.getElementById('banForm').addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
reloadOnSuccess(api.action(base + '/ban', { banned: true, days: parseInt(document.getElementById('banDays').value, 10),
|
|
reason: document.getElementById('banReason').value.trim() }));
|
|
});
|
|
(function () {
|
|
var details = document.getElementById('banDetails');
|
|
var expires = details ? parseInt(details.dataset.expires, 10) : 0;
|
|
if (details) details.textContent = expires > 0 ? 'until ' + new Date(expires * 1000).toLocaleString() : 'permanently';
|
|
})();
|
|
|
|
// ---- moderation history ----
|
|
var NOTE_KINDS = { note: ['Note', 'secondary'], warning: ['Warning', 'warning'], ban: ['Ban', 'danger'], unban: ['Unban', 'success'], mute: ['Mute', 'warning'],
|
|
unmute: ['Unmute', 'success'], lock: ['Lock', 'danger'], unlock: ['Unlock', 'success'] };
|
|
function loadNotes() {
|
|
var list = document.getElementById('noteList');
|
|
if (!list) return;
|
|
api.get(base + '/notes').then(function (d) {
|
|
if (!d.success) { list.innerHTML = '<li class="text-danger">' + esc(d.error) + '</li>'; return; }
|
|
var warnings = d.notes.filter(function (n) { return n.kind === 'warning'; }).length;
|
|
document.getElementById('warningCount').textContent = warnings ? warnings + ' warning' + (warnings === 1 ? '' : 's') : '';
|
|
list.innerHTML = d.notes.map(function (n) {
|
|
var k = NOTE_KINDS[n.kind] || [n.kind, 'secondary'];
|
|
return '<li class="border-bottom py-2 d-flex gap-2"><span>' + fmt.badge(k[0], k[1]) + '</span><div class="flex-grow-1"><div>' + esc(n.text) + '</div>' +
|
|
'<div class="small text-body-secondary">' + esc(n.actor || 'unknown') + ', ' + esc(fmt.unix(n.created_at)) + '</div></div>' +
|
|
(DASH.can('accounts_manage') && accountEl.dataset.canModerate ? '<button class="btn btn-sm btn-link text-danger p-0" data-delete-note="' + n.id + '" aria-label="Delete">×</button>' : '') + '</li>';
|
|
}).join('') || '<li class="text-body-secondary">Nothing yet.</li>';
|
|
});
|
|
}
|
|
if (document.getElementById('noteForm')) {
|
|
document.getElementById('noteKind').addEventListener('change', function (e) {
|
|
document.getElementById('tellPlayerWrap').classList.toggle('d-none', e.target.value !== 'warning');
|
|
});
|
|
document.getElementById('noteForm').addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
var kind = document.getElementById('noteKind').value;
|
|
api.action(base + '/notes', { kind: kind, text: document.getElementById('noteText').value.trim(), tell_player: kind === 'warning' && document.getElementById('tellPlayer').checked })
|
|
.then(function (d) { toast(d.message, 'success'); document.getElementById('noteText').value = ''; loadNotes(); }).catch(function () {});
|
|
});
|
|
document.getElementById('noteList').addEventListener('click', function (e) {
|
|
var del = e.target.closest('[data-delete-note]');
|
|
if (del && confirm('Delete this entry from the history?')) api.action('/api/account_notes/' + del.dataset.deleteNote + '/delete', {}).then(loadNotes).catch(function () {});
|
|
});
|
|
loadNotes();
|
|
// Only changes to this account's history; throttled after that check, since a throttled call gets no event
|
|
if (window.Live) {
|
|
var reloadNotes = Live.throttle(loadNotes, 1000);
|
|
Live.on('account_notes', function (e) { if (!e.id || String(e.id) === String(accountId)) reloadNotes(); });
|
|
}
|
|
}
|
|
function toggleLock(locked) {
|
|
if (!confirm((locked ? 'Lock' : 'Unlock') + ' this account?')) return;
|
|
reloadOnSuccess(api.action(base + '/lock', { locked: locked }));
|
|
}
|
|
function setGmLevel() {
|
|
var level = parseInt(document.getElementById('gmLevelSelect').value, 10);
|
|
if (!confirm('Set GM level to ' + level + '?')) return;
|
|
reloadOnSuccess(api.action(base + '/gmlevel', { gm_level: level }));
|
|
}
|
|
function setMute() {
|
|
reloadOnSuccess(api.action(base + '/mute', { days: parseInt(document.getElementById('muteDuration').value, 10), reason: document.getElementById('muteReason').value.trim() }));
|
|
}
|
|
function deleteAccount() {
|
|
var body = {};
|
|
if (accountEl.dataset.self) {
|
|
// Your own account: the server wants your username typed out
|
|
var name = accountEl.getAttribute('data-account-name');
|
|
var typed = prompt('This PERMANENTLY deletes YOUR OWN account and all its characters, and signs you out. Type your username (' + name + ') to confirm.');
|
|
if (typed === null) return;
|
|
body.confirm = typed.trim();
|
|
} else {
|
|
if (!confirm('PERMANENTLY delete this account and all its characters? This cannot be undone.')) return;
|
|
if (!confirm('Are you absolutely sure?')) return;
|
|
}
|
|
api.action(base + '/delete', body).then(function (d) { return d.result; }).then(function () { if (accountEl.dataset.self) window.location.href = '/login'; else goTo('/accounts', { replace: true }); }).catch(function () {});
|
|
}
|
|
function resetPassword() {
|
|
var pwd = document.getElementById('newPwd').value;
|
|
if (!confirm('Reset password for this account?')) return;
|
|
api.action(base + '/password', { password: pwd }, 'Password reset').then(function () {
|
|
bootstrap.Modal.getInstance(document.getElementById('passwordModal')).hide();
|
|
document.getElementById('newPwd').value = '';
|
|
}).catch(function () {});
|
|
}
|
|
function changeOwnPassword() {
|
|
var current = document.getElementById('currentPwd');
|
|
var next = document.getElementById('ownNewPwd');
|
|
api.action('/api/account/password', { current_password: current.value, new_password: next.value }, 'Password changed').then(function () {
|
|
current.value = '';
|
|
next.value = '';
|
|
}).catch(function () {});
|
|
}
|
|
function setAccountEmail() {
|
|
var email = prompt('Email address for this account (leave empty to remove). A confirmation link is sent to it.', accountEl.getAttribute('data-email') || '');
|
|
if (email === null) return;
|
|
reloadOnSuccess(api.action(base + '/email', { email: email.trim() }));
|
|
}
|
|
function sendPasswordReset() {
|
|
if (!confirm('Email this account a password reset link?')) return;
|
|
api.action(base + '/send_password_reset', {}).catch(function () {});
|
|
}
|
|
function saveOwnEmail() {
|
|
var pwd = document.getElementById('emailPwd');
|
|
api.action('/api/account/email', { email: document.getElementById('ownEmail').value.trim(), current_password: pwd.value }).then(function (d) {
|
|
pwd.value = '';
|
|
toast(d.message, 'success');
|
|
return d.result;
|
|
}).then(function () { reloadInPlace(); }).catch(function () {});
|
|
}
|
|
function resendVerification() {
|
|
api.action('/api/account/email/resend', {}).catch(function () {});
|
|
}
|
|
function sendTestEmail() {
|
|
api.action('/api/email/test', { to: document.getElementById('testEmailTo').value.trim() }).then(function (d) {
|
|
return d.result;
|
|
}).then(loadEmailStatus).catch(function () {});
|
|
}
|
|
function loadEmailStatus() {
|
|
var list = document.getElementById('emailStatus');
|
|
if (!list) return;
|
|
api.get('/api/email/status').then(function (s) {
|
|
var rows = [
|
|
['Status', s.configured ? (s.auth === 'oauth2' && !s.connected ? 'Configured, no mail account connected' : 'Enabled') : 'Disabled'],
|
|
['Server', s.host || '-'],
|
|
['Sender', s.from || '-'],
|
|
['Sign-in', s.auth === 'oauth2' ? 'OAuth2 (' + (s.provider || '?') + ', ' + (s.grant || '?') + ')' : 'Password']
|
|
];
|
|
if (s.auth === 'oauth2') rows.push(['Mailbox', (s.account || '-') + (s.connected ? ' (connected)' : '')]);
|
|
list.innerHTML = rows.map(function (r) { return '<dt class="col-sm-4">' + esc(r[0]) + '</dt><dd class="col-sm-8">' + esc(r[1]) + '</dd>'; }).join('');
|
|
|
|
var error = document.getElementById('emailError');
|
|
error.textContent = s.error || '';
|
|
error.classList.toggle('d-none', !s.error);
|
|
|
|
var controls = document.getElementById('oauthControls');
|
|
controls.classList.toggle('d-none', !s.canConnect && !(s.auth === 'oauth2' && s.connected));
|
|
document.getElementById('oauthConnect').classList.toggle('d-none', !s.canConnect);
|
|
document.getElementById('oauthConnect').textContent = s.connected ? 'Reconnect mail account' : 'Connect mail account';
|
|
document.getElementById('oauthDisconnect').classList.toggle('d-none', !(s.canConnect && s.connected));
|
|
document.getElementById('oauthRedirect').textContent = s.redirectUri || '';
|
|
}).catch(function () {});
|
|
}
|
|
function connectMailAccount() {
|
|
api.action('/api/email/oauth2/start', {}).then(function (d) { window.location.href = d.url; }).catch(function () {});
|
|
}
|
|
function disconnectMailAccount() {
|
|
if (!confirm('Disconnect the mail account? Emails will stop sending until one is connected again.')) return;
|
|
api.action('/api/email/oauth2/disconnect', {}).then(function (d) { return d.result; }).then(loadEmailStatus).catch(function () {});
|
|
}
|
|
</script>
|
|
<script src="/js/related.js"></script>
|
|
<script src="/js/strikes.js"></script>
|
|
<script src="/js/account-links.js"></script>
|
|
<script src="/js/client-sysinfo.js"></script>
|
|
<script src="/js/api-keys.js"></script>
|
|
<script src="/js/grants.js"></script>
|
|
<script>
|
|
(function () {
|
|
var panel = document.getElementById('grantsPanel');
|
|
if (panel) Grants.mount(panel, { type: 'account', id: panel.dataset.account });
|
|
})();
|
|
</script>
|
|
{% endblock %}
|