Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e7d2d880c | ||
|
|
63efd724d2 | ||
|
|
ba4781910d | ||
|
|
08c85a42e1 | ||
|
|
fc5f6f43d6 | ||
|
|
9b22ef810e | ||
|
|
dbc0eea148 | ||
|
|
e80412b6fd | ||
|
|
2c825af689 | ||
|
|
4cfee4f672 | ||
|
|
7076f1ded8 | ||
|
|
9fbcdf7a5f | ||
|
|
7f35d792b2 | ||
|
|
c9cda3e203 | ||
|
|
3540bf42d9 | ||
|
|
e6782e61a8 | ||
|
|
33e655bcce | ||
|
|
15a67a14b3 | ||
|
|
b48eef2189 | ||
|
|
280f5bc675 | ||
|
|
1756f5ac1c | ||
|
|
c9435f7580 | ||
|
|
426d344aa8 | ||
|
|
ed90e096b5 | ||
|
|
eb6e5f589e | ||
|
|
eca837962c | ||
|
|
b75bbaa517 | ||
|
|
9763ffa2a3 | ||
|
|
d854b903e0 | ||
|
|
5008857880 | ||
|
|
628966d3b5 | ||
|
|
d45c8ef511 | ||
|
|
ba90ec1f7a | ||
|
|
5023d31202 | ||
|
|
1c44f59ba1 | ||
|
|
433a63bf1a | ||
|
|
de847b9e9f | ||
|
|
d530d9614b | ||
|
|
9a92271f6f | ||
|
|
97ddf935a8 | ||
|
|
973ce1e5bd | ||
|
|
7003020855 | ||
|
|
5cdc5997af | ||
|
|
c3b4707f85 | ||
|
|
2b3094a4ef | ||
|
|
ecd0467ffa | ||
|
|
43ac0725ce | ||
|
|
98879802ae | ||
|
|
cf9292262d | ||
|
|
3d2ed71f3a | ||
|
|
dcd2f4b079 | ||
|
|
b2d24670ff | ||
|
|
fa261fecfd | ||
|
|
40f36baf94 | ||
|
|
400703154e | ||
|
|
3ceada7b8a | ||
|
|
eb0643d887 | ||
|
|
1fc670138b | ||
|
|
313a1fcce9 | ||
|
|
7efd8d59bf | ||
|
|
c2eb2c081b | ||
|
|
b299a0b602 | ||
|
|
f275fbe2e4 | ||
|
|
f134766fd1 | ||
|
|
6ebf720688 | ||
|
|
b1eb2b3c9b | ||
|
|
48aa607b56 | ||
|
|
088810bd20 | ||
|
|
e16f572252 | ||
|
|
9f312aa556 | ||
|
|
c5ac68e137 | ||
|
|
ed6b5d5f33 | ||
|
|
7a72903632 | ||
|
|
92127e2f00 | ||
|
|
be5ff96e4d | ||
|
|
1f47b7a6a9 | ||
|
|
551984ac44 | ||
|
|
8c5aec9ca4 | ||
|
|
375220298d | ||
|
|
452976ed95 | ||
|
|
243a2adfbf | ||
|
|
1ba4a13353 | ||
|
|
5de12dfb79 | ||
|
|
689d646c57 | ||
|
|
49cd7f8130 | ||
|
|
4545e212f4 | ||
|
|
b1f4f6eae0 | ||
|
|
9a431a873b | ||
|
|
bb7e1c4538 | ||
|
|
356abcfc2d | ||
|
|
3099b4801e | ||
|
|
fc641e94ac | ||
|
|
0e758409ee | ||
|
|
8c93941d8d | ||
|
|
4221c9a50f | ||
|
|
3a559479bd | ||
|
|
482493a10d | ||
|
|
0e1036eb49 | ||
|
|
349406723c | ||
|
|
997bedc91b | ||
|
|
307e6d5d34 | ||
|
|
ca1108f455 | ||
|
|
0ff88f36ed | ||
|
|
285b4e349c | ||
|
|
2b4ebb1fbb | ||
|
|
a829c2818f | ||
|
|
c54cf73c3a | ||
|
|
c45ef86924 | ||
|
|
f39366b470 | ||
|
|
b725000f4d | ||
|
|
105194a8b9 | ||
|
|
8dbb538c98 | ||
|
|
e435356c53 | ||
|
|
6f9982540c | ||
|
|
d0d6632b24 | ||
|
|
4b7009dfc2 | ||
|
|
55a408e810 | ||
|
|
d5dddba6df | ||
|
|
d1a0667c79 | ||
|
|
e700e4fd04 | ||
|
|
cf993c1036 | ||
|
|
5fdf226ebe | ||
|
|
fae15f073e | ||
|
|
c646c87030 | ||
|
|
b4a76bc4d1 | ||
|
|
dfe886636b | ||
|
|
f499e87d2a | ||
|
|
32fe871b70 | ||
|
|
aab19379e2 | ||
|
|
b46a1a69e8 | ||
|
|
ea424cad7e | ||
|
|
3f444a8912 | ||
|
|
8b0e2052cf | ||
|
|
c99934a92c | ||
|
|
ce2731cd9d | ||
|
|
f9f8af2f11 | ||
|
|
d8e2a10806 | ||
|
|
869ee07ebc | ||
|
|
2ad2bb1e09 | ||
|
|
23bc31c661 | ||
|
|
a2f76037a1 | ||
|
|
9571f2e185 | ||
|
|
887b9c728c | ||
|
|
8c21f462c2 | ||
|
|
5f3d2d3e4a | ||
|
|
4bce80b8ba | ||
|
|
1d09f5a623 | ||
|
|
2c513129f2 | ||
|
|
b3dc2e32b8 | ||
|
|
b0640c9ecc | ||
|
|
2d7e24b513 | ||
|
|
5b30bacf10 | ||
|
|
fe937403f3 | ||
|
|
876ea370e4 | ||
|
|
1dcdeeae86 | ||
|
|
01302a775c | ||
|
|
76d78ae756 | ||
|
|
51745ea03d | ||
|
|
c44a9ce6e0 | ||
|
|
7fa65796f0 | ||
|
|
d09df7e8a3 | ||
|
|
090399a308 | ||
|
|
c31a58af1a | ||
|
|
65aabb943c | ||
|
|
9c8739c4bb | ||
|
|
48f72be209 | ||
|
|
92fb0c63e9 | ||
|
|
55596556ef | ||
|
|
abd63d124f | ||
|
|
562080b7a3 | ||
|
|
41c9f4926c | ||
|
|
e7e78072d4 | ||
|
|
cd363b4840 | ||
|
|
3a350c14a6 | ||
|
|
b0765bf085 | ||
|
|
9225ba0790 | ||
|
|
3b36738192 | ||
|
|
3edd35ab57 | ||
|
|
a86a96e390 | ||
|
|
5f464d4ee2 | ||
|
|
bd72dec98f | ||
|
|
1331a3767c | ||
|
|
cb200330e7 | ||
|
|
178922323d | ||
|
|
91cf125a5d | ||
|
|
f4b7ef8117 | ||
|
|
d175fc2983 | ||
|
|
2f8bbdc636 | ||
|
|
b1573aada1 | ||
|
|
1cd6cde77c | ||
|
|
802a30508a | ||
|
|
70c1ddd48c | ||
|
|
2dc8537780 | ||
|
|
128d7d0401 | ||
|
|
d3d79be64c | ||
|
|
43475945bf | ||
|
|
09302684da | ||
|
|
e68fcb4aec | ||
|
|
ded13f02cc | ||
|
|
cfdee5e5c1 | ||
|
|
e7648eb1ac | ||
|
|
e7be3d1e0c | ||
|
|
9639a6bb75 | ||
|
|
904a62ce79 | ||
|
|
2903e56cf6 | ||
|
|
b5e0189938 | ||
|
|
a44bd7c3e6 | ||
|
|
0885d3c13e | ||
|
|
ef8eb1d73b | ||
|
|
9f67bc078a | ||
|
|
da411af6d3 | ||
|
|
265908b05b | ||
|
|
2a769c2b0a | ||
|
|
28054c81ea | ||
|
|
e7264f521c | ||
|
|
1b0ca8967e | ||
|
|
3e336d459c | ||
|
|
94f55afd1f | ||
|
|
7b058ed0ac | ||
|
|
853b0eb855 | ||
|
|
41a458d872 | ||
|
|
d1c5dba7d7 | ||
|
|
8c50abe221 | ||
|
|
07367a8a5d | ||
|
|
1a50788c91 | ||
|
|
0e06bfe273 | ||
|
|
f68e41d81a | ||
|
|
8b164c556e | ||
|
|
2e1f53c899 | ||
|
|
a6d2efaf74 | ||
|
|
01cd9644ed | ||
|
|
1521826d37 | ||
|
|
0d218d0d2a | ||
|
|
9777dd655c | ||
|
|
f970fd1822 | ||
|
|
5e096240b3 | ||
|
|
bc97a1ac10 | ||
|
|
4594fb2572 | ||
|
|
5319562c94 | ||
|
|
599fa66822 | ||
|
|
8041700668 | ||
|
|
bade68a8b8 | ||
|
|
4be7176802 | ||
|
|
8813533958 | ||
|
|
affa239d75 | ||
|
|
5d292fa43f | ||
|
|
878df6bb49 | ||
|
|
607a9584fd | ||
|
|
b8e2bfd793 | ||
|
|
4ad6d37877 | ||
|
|
d7c29b7bec | ||
|
|
b86bc541ab | ||
|
|
8c74e01a40 | ||
|
|
231a9017d2 | ||
|
|
9a5bb78b18 | ||
|
|
eecf16daa2 | ||
|
|
210150a02e | ||
|
|
e50691d6c4 | ||
|
|
089963b1b3 | ||
|
|
4af952613a | ||
|
|
0d9fa0285f | ||
|
|
683fe75864 | ||
|
|
e9c9be84ad | ||
|
|
7822a363dd | ||
|
|
1e535e96a2 | ||
|
|
32135ddb95 | ||
|
|
841513e510 | ||
|
|
5964b2e456 | ||
|
|
6a8ad525f1 | ||
|
|
31d17098d6 | ||
|
|
3f97e6ed8d | ||
|
|
2dea33e698 | ||
|
|
123764f8b8 | ||
|
|
ec0f355c13 | ||
|
|
c555973b6b | ||
|
|
a8db02e881 | ||
|
|
7dc5984359 | ||
|
|
1c3003421e | ||
|
|
f3d9115ecd | ||
|
|
4400a7abba | ||
|
|
45a4db1c22 | ||
|
|
65eef4b2b8 | ||
|
|
25de7d996c | ||
|
|
c406fbb73e | ||
|
|
31024396e3 | ||
|
|
f0967f90eb | ||
|
|
a4bb8e0c28 | ||
|
|
7188abc9bc | ||
|
|
4a91cd0c44 | ||
|
|
6abf8a5dd8 | ||
|
|
3667c842c6 | ||
|
|
b64721b43c | ||
|
|
6b5ca2cb89 | ||
|
|
0f6e4f995f | ||
|
|
0b6fdcabfb | ||
|
|
fc49fe0687 | ||
|
|
419382d25d | ||
|
|
8935b81f12 | ||
|
|
ec581ce53e | ||
|
|
81d8465a79 | ||
|
|
0f3b506604 | ||
|
|
1b84547211 | ||
|
|
dafc8ace3c | ||
|
|
2c419cc4fe | ||
|
|
90acf181f3 | ||
|
|
54af07f2af | ||
|
|
68f1fabc4b | ||
|
|
27451807db | ||
|
|
55099bdcbb | ||
|
|
71eb720065 | ||
|
|
8abb0c8717 | ||
|
|
3043639d2d | ||
|
|
e9b3eacbb7 | ||
|
|
f37e55e285 | ||
|
|
c0af2dbdd1 | ||
|
|
ae517732f7 | ||
|
|
3e1de10213 | ||
|
|
511740bb6d | ||
|
|
4a24d2a11d | ||
|
|
aadf56c27b | ||
|
|
9f8588eadc | ||
|
|
d657aec391 | ||
|
|
e683c90404 | ||
|
|
cfb4a23c9d | ||
|
|
fe1d4861bb | ||
|
|
5aa9b1d5f9 | ||
|
|
0913dbd3e4 | ||
|
|
29197ea355 | ||
|
|
4788e8a91a | ||
|
|
b80678b00f | ||
|
|
4f7c9c332b | ||
|
|
da103ff06f | ||
|
|
2111c77870 | ||
|
|
e5083ec1df | ||
|
|
df8d04e233 | ||
|
|
9a11a18a44 | ||
|
|
ce9f7af330 | ||
|
|
081e8a0310 | ||
|
|
6c3529b368 | ||
|
|
077a4f03a7 | ||
|
|
b04dfaf252 | ||
|
|
9c7452e7fd | ||
|
|
ec5593f567 | ||
|
|
c30c38a7af | ||
|
|
7494fc1776 | ||
|
|
16fe92d2e9 | ||
|
|
3544e6a9e7 | ||
|
|
dd1f3e11e6 | ||
|
|
6503482b55 | ||
|
|
cab57f6cd7 | ||
|
|
27f4fbdce4 | ||
|
|
39a228b20e | ||
|
|
4b069808d6 | ||
|
|
468851ff25 | ||
|
|
b7374570c8 | ||
|
|
3c9fa5dc25 | ||
|
|
3ade1c6473 | ||
|
|
1810a474a2 | ||
|
|
40982bc37b | ||
|
|
76f6149841 | ||
|
|
7d6a3c8c76 | ||
|
|
361ad49f5f | ||
|
|
f9aa5cbaee | ||
|
|
3f36045990 | ||
|
|
9a44babcf1 | ||
|
|
f032758303 | ||
|
|
92c7f74420 | ||
|
|
c2e4518cfa | ||
|
|
00f33afdf9 | ||
|
|
e566cfe687 | ||
|
|
6b7c849332 | ||
|
|
30c4afb977 | ||
|
|
1f60671886 | ||
|
|
794001fdbd | ||
|
|
9ad2facad3 | ||
|
|
89d8282846 | ||
|
|
c3960a99be | ||
|
|
e73ffa7449 | ||
|
|
37bd490072 | ||
|
|
24c53e5ce7 | ||
|
|
aa7f886795 | ||
|
|
00dec8c5a0 | ||
|
|
578e60c0bc | ||
|
|
8b21851353 | ||
|
|
15006086d2 | ||
|
|
bc3b923945 | ||
|
|
76ba9e5f85 | ||
|
|
44eb5fced2 | ||
|
|
172d8267ef | ||
|
|
f162f1e3d4 | ||
|
|
6fa0029d0b | ||
|
|
028e78a0c9 | ||
|
|
440a4e919a | ||
|
|
b8f39198e1 | ||
|
|
966bbe3957 | ||
|
|
522bf6a019 | ||
|
|
1689315c3a | ||
|
|
d4f7ae522e | ||
|
|
f05f70a9e5 | ||
|
|
850ee73048 | ||
|
|
5842f3f914 |
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:github.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+47
-9
@@ -27,11 +27,6 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Set to "false" to disable if using a non-Stalwart JMAP server.
|
||||
# STALWART_FEATURES=true
|
||||
|
||||
# If your reverse proxy doesn't forward Stalwart management API paths
|
||||
# (/api/account/*, /api/principal/*), set this to the URL where Stalwart's
|
||||
# HTTP listener is directly reachable. Defaults to JMAP_SERVER_URL if not set.
|
||||
# STALWART_API_URL=https://admin.example.com
|
||||
|
||||
# =============================================================================
|
||||
# OAuth / OpenID Connect (optional)
|
||||
# =============================================================================
|
||||
@@ -76,11 +71,53 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
|
||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||
# For Docker, the working directory is /app, so the default resolves to
|
||||
# /app/data/settings - mount a persistent volume there:
|
||||
# volumes:
|
||||
# - bulwark-settings:/app/data/settings
|
||||
# /app/data/settings - mount a persistent volume there (see docker-compose.yml).
|
||||
# SETTINGS_DATA_DIR=./data/settings
|
||||
|
||||
# =============================================================================
|
||||
# Admin Dashboard Data
|
||||
# =============================================================================
|
||||
|
||||
# Admin data is split across two directories so the config volume can be
|
||||
# mounted read-only after the setup wizard completes (see issue #226).
|
||||
#
|
||||
# Config dir - operator-authored state. Holds config.json, policy.json,
|
||||
# admin.json (passwordHash only), plugin-config/, plugins/, themes/, and
|
||||
# branding uploads. Safe to mount read-only after setup.
|
||||
# Default: ./data/admin (or ADMIN_DATA_DIR if that legacy variable is set)
|
||||
# ADMIN_CONFIG_DIR=./data/admin
|
||||
#
|
||||
# State dir - runtime mutations. Holds admin-state.json (login timestamps),
|
||||
# audit.log, and the bootstrap setup token. Always read-write.
|
||||
# Default: ./data/admin-state (or ADMIN_DATA_DIR/state when ADMIN_DATA_DIR
|
||||
# is set, for back-compat with single-volume installs)
|
||||
# ADMIN_STATE_DIR=./data/admin-state
|
||||
#
|
||||
# Set to "true" to enforce read-only mode at the application layer (cleaner
|
||||
# error than a mid-request EROFS). Pair with `:ro` on the config-volume mount.
|
||||
# ADMIN_CONFIG_READONLY=true
|
||||
#
|
||||
# Legacy: a single dir containing both config and state. Honoured if neither
|
||||
# of the split variables is set. New installs should use the split vars.
|
||||
# ADMIN_DATA_DIR=./data/admin
|
||||
|
||||
# =============================================================================
|
||||
# Anonymous Telemetry
|
||||
# =============================================================================
|
||||
|
||||
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
|
||||
# version, platform, bucketed account counts, and feature toggles only. See
|
||||
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
|
||||
#
|
||||
# Disable telemetry entirely (overrides the admin UI):
|
||||
# BULWARK_TELEMETRY=off
|
||||
|
||||
# Directory for telemetry state: instance id, consent, login HMACs
|
||||
# (default: ./data/telemetry). For Docker, the default resolves to
|
||||
# /app/data/telemetry - mount a persistent volume there (see docker-compose.yml)
|
||||
# so the instance id and consent choice survive upgrades.
|
||||
# TELEMETRY_DATA_DIR=./data/telemetry
|
||||
|
||||
# =============================================================================
|
||||
# Server Listen Address
|
||||
# =============================================================================
|
||||
@@ -188,7 +225,8 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
# =============================================================================
|
||||
|
||||
# URL of the BulwarkMail extension directory for the admin marketplace.
|
||||
# Set this to enable browsing and installing plugins/themes from the directory.
|
||||
# Defaults to https://extensions.bulwarkmail.org. Override only if you run
|
||||
# your own directory (e.g. http://localhost:3001 for local development).
|
||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -50,6 +50,8 @@ jobs:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
@@ -78,6 +78,8 @@ jobs:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
+453
@@ -1,5 +1,458 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.0 (2026-05-21)
|
||||
|
||||
> **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Plugins**: Plugins now run inside a null-origin iframe sandbox and talk to the host over a postMessage RPC bridge. The in-process plugin runtime is gone; the bundled in-tree plugins have been migrated. Third-party plugins built against the old in-process API need to be ported to the sandboxed runtime.
|
||||
- **Plugins**: Server-managed bundles must be Ed25519-signed by the host and approved by an admin before they load. The host public key is served from `/api/plugin-signing-pubkey` and each bundle response carries the signature in the `X-Bundle-Signature` header. User-uploaded bundles still load unsigned, but managed marketplace and dev-folder bundles do not.
|
||||
- **Plugins**: `bundleHash` is now a full SHA-256 over the bundle. Legacy short hashes are migrated on first load; any out-of-band tooling that pinned the old hash format needs to be updated.
|
||||
|
||||
### Features
|
||||
|
||||
- **Pro**: Tabbed shell with drag-to-reorder, drag-to-edge to split, side-by-side panes, and pane-aware responsive layout with a scoped sidebar overlay
|
||||
- **Pro**: Auto-redirect to the Pro shell when Pro mode is on; `proInterface` is kept per-device instead of syncing
|
||||
- **Pro**: Multi-account mail sidebar with client routing and a per-account mailbox cache
|
||||
- **Pro**: Unified mailbox always visible, with full-text search
|
||||
- **Pro**: Cross-account email moves
|
||||
- **Pro**: Multi-account calendar sidebar split into owned vs shared per account
|
||||
- **Pro**: Multi-account contacts and a cross-account file picker
|
||||
- **Pro**: Composer From dropdown grouped by account
|
||||
- **Plugins**: Per-plugin admin approval workflow with Ed25519 bundle signing verified on load
|
||||
- **Setup**: Allow the setup wizard over plain HTTP with a dismissable warning gate
|
||||
- **Setup**: Warn when the JMAP URL points at a local-only host
|
||||
- **Account**: List and reorder logged-in accounts from settings (#282)
|
||||
- **Mail**: Mobile handoff page with JMAP authentication verification for cross-device OAuth
|
||||
- **Mail**: Pluggable reply/forward quote header (#295)
|
||||
- **Calendar**: Support multiple flexible event reminders (#170)
|
||||
- **Admin**: Expose PWA, app identity, and extension directory keys in the JSON config (#312)
|
||||
- **Admin**: Surface OAuth scope settings and wire up orphaned admin policy gates
|
||||
|
||||
### Security
|
||||
|
||||
- **Plugins**: Pin parent origin in the iframe bridge to block cross-frame postMessage
|
||||
- **Plugins**: Ignore plugin-supplied `target` in `ui.openExternalUrl` to block host-frame hijack
|
||||
- **Plugins**: Validate plugin/theme id in marketplace install to block path traversal
|
||||
- **Plugins**: Prevent plugin config from leaking to non-admin users
|
||||
- **Admin**: Gate admin routes against cross-origin CSRF
|
||||
- **Auth**: Bind Stalwart auth context to the credential, not the cookie-claimed username
|
||||
- **Auth**: Validate OAuth discovery endpoints against SSRF
|
||||
- **Mail**: Tighten HTML sanitization at plain-text email, signature, and i18n render sites
|
||||
- **Mail**: Block script-bearing MIME types from inline attachment preview
|
||||
- **Mail**: Escape print-window fields and re-sanitize body to block XSS
|
||||
- **S/MIME**: Stop persisting passphrases in `sessionStorage`
|
||||
- **API**: Correct regex for valid API POST path validation
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Serialize draft autosave with send to stop replies stalling in Drafts (#303)
|
||||
- **Mail**: Omit empty cc/bcc from `Email/set` so the server does not emit a bare `Cc:` header (#301)
|
||||
- **Mobile**: Allow adding contacts from the mail recipient popover (#306)
|
||||
- **Mobile**: Prevent dual-scroll and use full width for mail content
|
||||
- **Mobile**: OAuth handoff flow
|
||||
- **Calendar**: Scope iCal subscriptions per JMAP account; fix refresh and clear
|
||||
- **Calendar**: iCal subscription refresh, rollback, and URL normalization
|
||||
- **Calendar**: Show avatars in the calendar/address book sharing menu
|
||||
- **Contacts**: Normalize malformed contact photo data URIs (#307)
|
||||
- **Identity**: Clear identity signature fields when emptied
|
||||
- **Identity**: Show size cap on identity signature fields
|
||||
- **Identity**: Allow table-based layouts in the HTML signature sanitizer
|
||||
- **Plugins**: Load `globals.css` and Geist font in the plugin sandbox iframe
|
||||
- **Plugins**: Sync plugin slot iframe height with reported content height
|
||||
- **Plugins**: Use plugin slot offer snapshots for `useSyncExternalStore`
|
||||
- **Filters**: Prevent duplication of Bulwark rules with literal braces in values
|
||||
- **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch
|
||||
- **Routing**: Anchor unmatched URLs into `main` so 404 renders
|
||||
- **Routing**: Respect server-resolved locale on first visit (#309)
|
||||
- **Routing**: Split app into `(main)`/`(sandbox)` route groups so the plugin iframe hydrates properly
|
||||
- **Files**: Stop parent directory navigation from jumping to root
|
||||
- **Build**: Stop pulling `node:dns` into the client bundle via OAuth discovery
|
||||
- **UI**: Toggle recipient popover when clicking the name again
|
||||
- **UI**: Remove white halo around photo avatars
|
||||
|
||||
### i18n
|
||||
|
||||
- Add missing translation keys across 16 locales
|
||||
|
||||
## 1.6.7 (2026-05-17)
|
||||
|
||||
### Features
|
||||
|
||||
- **Contacts**: vCard 4.0 parsing and generation support
|
||||
- **Admin**: Master-user impersonation route with `app-top-banner` plugin slot rendered on every authenticated page
|
||||
- **Admin**: Allow admin password overwrite during setup recovery
|
||||
- **Setup**: HTTPS requirement warning in the setup wizard
|
||||
- **Mobile**: Show details toggle and expandable panel for sender info
|
||||
|
||||
### Performance
|
||||
|
||||
- **Calendar**: Speed up calendar invitation banner load
|
||||
|
||||
### Security
|
||||
|
||||
- **Mail**: Sandbox thread email HTML in `srcDoc` iframe with a CSP `<meta>` tag
|
||||
- **Admin**: Redact sensitive config secrets from the admin API response
|
||||
- **Admin**: Make impersonation cookies session-only
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Auth**: Read `OAUTH_SCOPES` at runtime instead of build time
|
||||
- **Auth**: Use a relative `Location` header in redirects
|
||||
- **Auth**: Adopt orphan session cookie on first SPA load
|
||||
- **Mail**: Per-account push subscriptions so multi-account notifications work (#298)
|
||||
- **Mail**: Close attachment preview when clicking outside the content area
|
||||
- **Mail**: Pin quick reply to the bottom for short emails
|
||||
- **Mail**: Show "no body content" instead of an infinite skeleton for bodyless emails
|
||||
- **Mail**: Show contact popup when clicking the sender name in the email header
|
||||
- **Mail**: Prevent long addresses from overflowing email details columns (#297)
|
||||
- **Mobile**: Align quick reply with the mobile bottom toolbar
|
||||
- **Mobile**: Respect safe-area insets on mobile bottom bars
|
||||
- **Mobile**: Pad `safe-area-inset-top`
|
||||
- **UI**: Apply dark background to the email content wrapper in dark mode
|
||||
- **UI**: Improve dark mode background colors in the email viewer
|
||||
- **UI**: Add viewport export with `initialScale: 1`
|
||||
- **UI**: Strip the Stalwart master-user `%` suffix from the displayed account
|
||||
- **Plugins**: Warn and block install when the app version is below the plugin's `minAppVersion`
|
||||
- **Plugins**: Register `app-top-banner` in plugin-store `SLOT_NAMES`
|
||||
- **Plugins**: Carry `configSchema` + `settingsSchema` through marketplace install
|
||||
- **Build**: Add `outputFileTracingExcludes` to reduce Turbopack memory tracing
|
||||
|
||||
### i18n
|
||||
|
||||
- Add missing translation keys across 16 locales
|
||||
|
||||
## 1.6.6 (2026-05-15)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Sync onboarding completion state across devices so the welcome flow only runs once per account (#285)
|
||||
- **Mail**: Distinct icons for Shared, Important, Memos, Scheduled, and Snoozed folders (#288)
|
||||
- **Compose**: Raise HTML identity signature length cap to 50,000 characters
|
||||
- **Compose**: Allow `<img>` tags in HTML identity signatures for inline logos and banners
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Files**: Hide Files settings entry and sidebar nav when the `filesEnabled` policy is off (#291)
|
||||
- **Admin**: Honor the `cookieSameSite` admin config override instead of always defaulting (#284)
|
||||
- **UI**: Standardize punctuation in tooltips and inline comments across locales
|
||||
|
||||
### i18n
|
||||
|
||||
- Add Danish localization
|
||||
- Clean up Danish locale wiring and sort the language picker alphabetically (#286)
|
||||
|
||||
## 1.6.5 (2026-05-13)
|
||||
|
||||
### Features
|
||||
|
||||
- **Protocol**: Register as the system handler for `mailto:` and `webcal:` links from a new protocol handler settings page
|
||||
- **Protocol**: Account picker for protocol links when multiple accounts are connected
|
||||
- **Protocol**: Import-or-subscribe choice for detected webcal calendars
|
||||
- **Protocol**: Reuse the open PWA/session for `mailto:` links instead of always opening a new tab
|
||||
- **UI**: Route account avatars through the shared `Avatar` component for consistent fallbacks (#278)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Calendar**: Support HTTP basic auth in iCal subscription URLs (#275)
|
||||
- **Admin**: Honor admin-uploaded favicon in root metadata (#274)
|
||||
- **Admin**: Honor `NEXT_PUBLIC_BASE_PATH` in admin sidebar nav links (#271)
|
||||
- **UI**: Broaden body font stack so Thai (and other non-Latin scripts) render correctly in subjects, sender names, and other chrome (#265)
|
||||
|
||||
## 1.6.4 (2026-05-11)
|
||||
|
||||
### Web Setup Wizard
|
||||
|
||||
First-launch web setup wizard. New installs no longer need to hand-edit `.env.local` - point a browser at the container and the wizard probes the JMAP server(s), configures OAuth/OIDC, generates the session secret, accepts branding uploads, and provisions the initial admin password. Admin storage is now split into `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps); the legacy `ADMIN_DATA_DIR` keeps working for existing installs.
|
||||
|
||||
### Features
|
||||
|
||||
- **Setup**: Web setup wizard with multi-step flow: Server, Auth, Security, Logging, Branding, Review, Admin
|
||||
- **Setup**: Admin config/state directory split with optional `ADMIN_CONFIG_READONLY` for immutable deployments (#226)
|
||||
- **Setup**: File uploads on the wizard branding step
|
||||
- **Setup**: Redesigned review step with grouped summary and an advanced toggle for the full config
|
||||
- **Setup**: Require explicit confirmation when JMAP probe finds no session
|
||||
- **Mail**: Drag attachments out of the viewer to the local file system (#267)
|
||||
- **Mail**: Reading Pane at Bottom mail layout (#262)
|
||||
- **Mail**: Configurable signature position - above or below quoted text (#266)
|
||||
- **Mail**: Signature position is now searchable from the email behavior settings
|
||||
- **Mail**: Show avatar in Focused list for compact density and above
|
||||
- **Mail**: Align Focused list preview with other layout previews
|
||||
- **Compose**: From-header override in the composer with catch-all auto-reply, replies to an alias on a domain you own pre-fill the alias as the sender even when it isn't a configured identity (#246)
|
||||
|
||||
### Performance
|
||||
|
||||
- **Mail**: Prefetch initial email data on login
|
||||
- **Auth**: Parallelize login round-trips and drop redundant JMAP re-verify
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Auth**: Skip upstream JMAP reverify for trusted URLs (#237)
|
||||
- **Auth**: Show account identity in the switcher header instead of the sending alias
|
||||
- **Compose**: Fall back to the primary identity signature on reply
|
||||
- **Setup**: Drop redundant first-login banner about removing `ADMIN_PASSWORD` (#222)
|
||||
- **UI**: Consistent notice cards for server probe results
|
||||
|
||||
### i18n
|
||||
|
||||
- Add missing translation keys across 15 locales
|
||||
|
||||
## 1.6.3 (2026-05-08)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Lift 5-account cap on HTTP/2
|
||||
- **Mail**: Import `.eml` files via folder right-click menu
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Trim leading whitespace from email list preview
|
||||
- **Mail**: Fall back when only the truncation indicator remains in email preview
|
||||
- **Mail**: Hide files/contacts nav items when JMAP server lacks support
|
||||
- **Viewer**: Preserve emoji colors in dark mode
|
||||
- **Viewer**: Prevent white-on-white in dark mode for nested `bgcolor` containers
|
||||
- **Viewer**: Render plain-text-only emails as text, not HTML
|
||||
- **Viewer**: Render HTML-only emails and redesign external content prompt
|
||||
- **Viewer**: Pad Word/Outlook HTML email rendering
|
||||
- **Compose**: Redesign quick reply to match sender/banner layout
|
||||
- **Compose**: Disable StarterKit's bundled link/underline to avoid duplicate extensions
|
||||
- **Sharing**: Request `shareWith` explicitly so calendar/address book shares survive a re-login (#257)
|
||||
- **UI**: Strip leading punctuation when computing avatar initials
|
||||
- **Mobile**: Hide email hover actions
|
||||
|
||||
### i18n
|
||||
|
||||
- Add missing translation keys across 15 locales
|
||||
|
||||
## 1.6.2 (2026-05-06)
|
||||
|
||||
### Features
|
||||
|
||||
- **Plugins**: Hot-reload and dev-folder loading for live plugin development
|
||||
- **Plugins**: On-demand `src/` bundling via esbuild
|
||||
- **Plugins**: New `http:fetch` permission and `httpOrigins` manifest field
|
||||
- **Plugins**: `onBeforeEmailSend` hook with `fromEmail` exposed on `OutgoingEmail`
|
||||
- **Plugins**: Project `EmailReadView` for the email-banner slot and expose auth results
|
||||
- **Plugins**: Ingest icon, banner, and screenshots from the source repo
|
||||
- **Plugins**: Restrict plugin and theme install/uninstall to the admin dashboard
|
||||
- **Mail**: Multi-server JMAP support
|
||||
- **Settings**: Fulltext search across the settings sidebar
|
||||
- **Settings**: Sub-result rows with highlight in settings search
|
||||
- **Settings**: Surface plugin settings as search sub-results
|
||||
- **Settings**: Remove experimental tags from themes, plugins, and sender favicons
|
||||
- **Viewer**: Redesigned external-mail banner above attachments
|
||||
- **Calendar**: Calendar invitation banner expands on row click
|
||||
- **Calendar**: Calendar invitation banner is now collapsible
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Admin**: Collapse admin panel into a single tabbed page
|
||||
- **Plugins**: Inline plugin configure panel to avoid dev-mode hang
|
||||
- **Plugins**: Resolve `PLUGIN_DEV_DIR` plugins in admin config route
|
||||
- **Plugins**: Add missing body type assertion in `createPluginAPI` fetch options
|
||||
- **Plugins**: Propagate `settingsSchema`
|
||||
- **Settings**: Highlight plugin and theme cards in search results
|
||||
- **Settings**: Open plugin card on first click of a setting sub-result
|
||||
- **Settings**: Drop ghost sub-results from account and language search
|
||||
- **Settings**: Improve search highlight styling
|
||||
- **Viewer**: Show notification banners above attachments
|
||||
- **Viewer**: Rework S/MIME banner to match calendar invitation
|
||||
- **Viewer**: Close PDF preview on Escape before email viewer
|
||||
- **Viewer**: Render PDF previews via `<object>` with `blob:` in object-src CSP (#253)
|
||||
- **Calendar**: Align invitation icon with sender avatar column
|
||||
- **Calendar**: Fix invitation picker clipping (#250)
|
||||
- **Auth**: Read `activeAccountId` from authStore in account selectors
|
||||
- **UI**: Adjust toast item border radius and progress bar styles
|
||||
- **UI**: Remove fly-in animation from context menu submenus
|
||||
- **i18n**: Add missing Czech flag icon
|
||||
|
||||
### i18n
|
||||
|
||||
- Add missing translation keys across 15 locales
|
||||
|
||||
## 1.6.1 (2026-05-04)
|
||||
|
||||
### Features
|
||||
|
||||
- **Updates**: Update-available detection with non-dismissible notice and dev-reload refresh
|
||||
- **Plugins**: New plugin hooks for compose, attachments, search, lifecycle, and routing
|
||||
- **Sharing**: Share indicators for calendars and contacts, updated JMAP capabilities (#244)
|
||||
- **Mail**: Auto-add recipients to trusted senders when replying
|
||||
- **Identity**: Sanitize identity display name to prevent invalid `From` headers
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mobile**: Synchronize mobile submenu view with browser history for better navigation
|
||||
- **Viewer**: Update email viewer styles to improve overflow handling
|
||||
- **Auth**: Ensure `cookieSlot` consistency during account updates in auth store
|
||||
- **Auth**: Thread per-account cookie slot through OAuth flows
|
||||
- **Calendar**: Square the colored left marker on calendar events
|
||||
- **About**: Show git commit in About instead of "unknown"
|
||||
|
||||
### i18n
|
||||
|
||||
- Update mailbox context menu translations across 12 locales
|
||||
|
||||
## 1.6.0 (2026-05-01)
|
||||
|
||||
### Features
|
||||
|
||||
- **Deployment**: Subpath deployment support via `NEXT_PUBLIC_BASE_PATH` environment variable
|
||||
- **Mail**: Image attachment thumbnails and preview chips
|
||||
- **Mobile**: Reworked mobile mail viewer toolbar
|
||||
- **Mobile**: Mobile-friendly settings panel
|
||||
- **Mobile**: Mobile-friendly admin panel
|
||||
- **Mail**: Redesigned expanded details panel
|
||||
- **Mailbox**: Show full path in mailbox context menu header with intelligent path shortening
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Viewer**: Respect per-email dark mode toggle when "always show in light mode" is on
|
||||
- **Navigation**: Scroll apps list in navigation rail to prevent overflow
|
||||
- **Context menu**: Clamp submenu inside viewport
|
||||
- **Context menu**: Prevent context menu from clipping below viewport
|
||||
- **Context menu**: Prevent jump and animation on open
|
||||
- **Mail**: Stop silently destroying emails when trash mailbox isn't found (#195)
|
||||
- **Mail**: Preserve list scroll position when tagging an email
|
||||
- **Mail**: Render below-header overflow popup outside clipped row
|
||||
- **Mail**: Collapse below-header attachments to single row with overflow pill
|
||||
- **Push**: Fix push preview JMAP query
|
||||
- **Tour**: Navigate tour to mailbox when starting from another page
|
||||
- **i18n**: Add `useTranslations` for "selected emails" and "cancel" on email list batch operations
|
||||
|
||||
### i18n
|
||||
|
||||
- Translate SPF/DKIM/DMARC tooltips
|
||||
- Add missing keys across 14 locales
|
||||
|
||||
## 1.5.4 (2026-05-01)
|
||||
|
||||
### Features
|
||||
|
||||
- **PWA**: Web push notifications for new inbox mail (#233), with click-through to open the message
|
||||
- **Composer**: Insert and edit tables in rich-text emails (#236)
|
||||
- **Mail**: Configurable sub-addressing delimiter character (#239)
|
||||
- **i18n**: Turkish localization
|
||||
- **i18n**: Missing keys filled in across 15 locales
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Set In-Reply-To and References headers on replies (#234)
|
||||
- **Mail**: Persist htmlBody in drafts to preserve rich formatting (#236)
|
||||
- **Auth**: Pin JMAP auth verification to the configured server URL (#237)
|
||||
- **Auth**: Evict unrecoverable basic-auth accounts on reload
|
||||
- **Notifications**: Scope new-mail notifications to genuine inbox deliveries
|
||||
- **Notifications**: Extend PushVerification timeout and clean up leftover subscriptions
|
||||
- **Viewer**: Smooth out body load to prevent flicker on first render
|
||||
- **Viewer**: Prevent iframe flash when loading images or trusting the sender
|
||||
- **Viewer**: Pad bare HTML emails like plain-text mails for consistent layout
|
||||
- **Viewer**: Light-mode override now only affects body content
|
||||
- **Viewer**: Detect `<style>` tag when applying padding
|
||||
- **Viewer**: Drop iframe border-radius
|
||||
- **Calendar**: Localize event start date in detail popover and event modal
|
||||
- **Dev**: Include http protocol in connect-src for development mode CSP
|
||||
|
||||
## 1.5.3 (2026-04-28)
|
||||
|
||||
> **New:** Help shape Bulwark Webmail. Each instance now sends a lightweight daily heartbeat (version, platform, bucketed account counts, feature toggles - never message data or PII) so we can see which platforms and features actually get used and prioritize fixes where they matter most. You're in control: opt out any time from **Admin → Telemetry** or by setting `BULWARK_TELEMETRY=off`. Full schema in the [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
|
||||
|
||||
### Features
|
||||
|
||||
- **Telemetry**: Anonymous instance telemetry, on by default. Reports schema version, platform, bucketed account counts, and feature toggles only - disable from the admin UI, with `BULWARK_TELEMETRY=off`, or by clearing the endpoint
|
||||
- **Telemetry**: Track unique logins (HMAC'd per instance, 90-day retention) so the heartbeat can report bucketed account totals without storing usernames
|
||||
- **Plugins**: Theme API v2 with token compiler and skin slot
|
||||
- **Plugins**: Extension preview page and detailed extension info API
|
||||
- **Calendar**: Right-click context menu on empty calendar space
|
||||
- **Docker**: Persistent named volume for telemetry data so the instance id and admin's consent choice survive container upgrades
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Security**: Block telemetry endpoint from pointing at internal/loopback hosts (validation + DNS-rebind re-check at fetch time)
|
||||
- **Security**: Harden plugin config, TOTP token exchange, and branding file serving
|
||||
- **Mail**: Batch shortcuts now act on the multi-selection when one is present (#228)
|
||||
|
||||
## 1.5.2 (2026-04-27)
|
||||
|
||||
### Features
|
||||
|
||||
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission - plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
|
||||
- **Plugins**: Manifests can declare `frameOrigins` - a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
|
||||
- **Calendar/Contacts**: JMAP sharing for calendars and address books
|
||||
- **i18n**: Czech language support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Security**: Validate URLs before outbound fetch
|
||||
- **Calendar**: Prevent drag creation on touch events in the time grid
|
||||
- **Contacts**: Emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import (#224, #187)
|
||||
- **Mail**: Hide preview line in compact density to match settings preview (#223)
|
||||
- **Proxy**: Inline matcher for Next.js proxy and drop unnecessary Node.js runtime config
|
||||
- **i18n**: Portuguese fixes for "ficheiro" and "contactos" variants
|
||||
|
||||
## 1.5.1 (2026-04-25)
|
||||
|
||||
### Features
|
||||
|
||||
- **Stalwart**: OAuth auto-setup with dialog and validation for origin and issuer URLs
|
||||
- **Mail**: Right-click context menu on the folders sidebar
|
||||
- **Mail**: Replace folder `prompt()` calls with a proper modal dialog
|
||||
- **Calendar**: Add 'Today' button to the desktop calendar toolbar
|
||||
- **Junk**: Setting to show avatars in the Junk folder (off by default)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Admin**: Restore admin panel after Stalwart v0.16 REST API removal
|
||||
- **Viewer**: Restore broken viewer toolbar actions and improve the mobile menu (#220)
|
||||
- **Folders**: Stop flicker on background folder refresh
|
||||
- **Email**: Preserve search/filter on batch move and archive
|
||||
- **Email**: Preserve search/filter when moving emails via drag-drop
|
||||
- **i18n**: Improve Korean flag
|
||||
|
||||
## 1.5.0 (2026-04-22)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Self-service portal now needs Stalwart 0.16+**: Stalwart dropped its self-service HTTP API in 0.16.0 and replaced it with JMAP. Bulwark Webmail only talks to the new JMAP endpoint, so the self-service portal (account settings, app passwords, API keys) requires Stalwart 0.16 or newer. `STALWART_API_URL` is deprecated, these actions go through the normal JMAP session.
|
||||
|
||||
### Features
|
||||
|
||||
- **Stalwart**: Migrate Stalwart management API to JMAP `x:` methods for Stalwart 0.16
|
||||
- **Admin**: Add API Keys management and IP allowlist for App Passwords
|
||||
- **Contacts**: Revamp contact detail view with filters, photo, print, and duplicate actions
|
||||
- **Contacts**: Add contact activity component showing recent emails and upcoming events
|
||||
- **Contacts**: Add right-click context menu
|
||||
- **Contacts**: Group contacts by first letter with sticky section headers, toggleable in settings
|
||||
- **Calendar**: Support resizing events from the top edge
|
||||
- **Calendar**: Add timezone-aware formatting for event start times and update `utcEnd` on duration change
|
||||
- **Calendar**: Optimize layout of overlapping events
|
||||
- **Calendar**: Add collapsible details to calendar invitation banner
|
||||
- **Email**: Implement batch archiving and bulk moving of emails
|
||||
- **Email**: Show full folder path in move/drop toast
|
||||
- **Settings**: Reorganize settings into 6 groups with clearer tabs
|
||||
- **Navigation**: Add account-addition button to the navigation rail
|
||||
- **Mobile**: Streamline email viewer header layout
|
||||
- **Mobile**: Pass `isMobile` through calendar views and time-grid interactions
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mailbox**: Retry mailbox fetch on first login to handle lazy provisioning (#217)
|
||||
- **Mailbox**: Use fresh state in archive handling to avoid stale mailbox data
|
||||
- **Mailbox**: Improve error message on mailbox creation failure
|
||||
- **Auth**: Skip `checkAuth` on route change when already authenticated
|
||||
- **Auth**: Clean up unused imports and improve TOTP QR code rendering
|
||||
- **UI**: Align hover styles and selection-toggle target with focused item
|
||||
- **UI**: Read `matchMedia` synchronously on client to prevent layout flicker
|
||||
|
||||
### Refactor
|
||||
|
||||
- **Settings**: Remove Stalwart API URL configuration (now derived via JMAP)
|
||||
|
||||
### Chore
|
||||
|
||||
- **i18n**: Add missing translation keys
|
||||
- **Deps**: Bump dependencies to latest compatible versions
|
||||
|
||||
## 1.4.14 (2026-04-16)
|
||||
|
||||
Thank you for your donations:
|
||||
|
||||
+26
-34
@@ -10,14 +10,17 @@
|
||||
|
||||
# Contributing to Bulwark Webmail
|
||||
|
||||
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
|
||||
We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale.
|
||||
|
||||
## Join our Community
|
||||
**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect.
|
||||
If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change.
|
||||
|
||||
* **Get Support:** Get real-time help with development hurdles.
|
||||
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
|
||||
* **Collaborate:** Meet the team and other contributors working to make Bulwark better.
|
||||
## Join the Community
|
||||
|
||||
You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this.
|
||||
|
||||
- **Get support** - real-time help with development hurdles
|
||||
- **Share ideas** - feature suggestions, design feedback, doc improvements
|
||||
- **Collaborate** - meet the team and other contributors
|
||||
|
||||
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
|
||||
|
||||
@@ -94,37 +97,31 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
This project uses **next-intl** for internationalization. Please follow these guidelines:
|
||||
This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh).
|
||||
|
||||
### Key Rules
|
||||
### Rules
|
||||
|
||||
1. **Never hardcode user-facing text** - Always use translations:
|
||||
1. **Never hardcode user-facing text** - always use translations:
|
||||
|
||||
```tsx
|
||||
const t = useTranslations("namespace");
|
||||
return <div>{t("key")}</div>;
|
||||
```
|
||||
|
||||
2. **Translation file locations**:
|
||||
- English: `/locales/en/common.json`
|
||||
- French: `/locales/fr/common.json`
|
||||
2. **Add new keys to `en/common.json` first.** Other locales can follow in the same PR or a follow-up - missing keys fall back to English.
|
||||
|
||||
3. **Namespace organization**:
|
||||
- `login.*` - Login page strings
|
||||
- `sidebar.*` - Sidebar navigation
|
||||
- `email_list.*` - Email list component
|
||||
- `email_viewer.*` - Email viewer component
|
||||
- `email_composer.*` - Email composer
|
||||
- `common.*` - Shared strings
|
||||
- `notifications.*` - Toast/alert messages
|
||||
- `settings.*` - Settings page
|
||||
- `login.*` - login page
|
||||
- `sidebar.*` - sidebar navigation
|
||||
- `email_list.*` - email list
|
||||
- `email_viewer.*` - email viewer
|
||||
- `email_composer.*` - composer
|
||||
- `settings.*` - settings page
|
||||
- `notifications.*` - toasts and alerts
|
||||
- `common.*` - shared strings
|
||||
|
||||
4. **Adding new strings**:
|
||||
- Add to **both** English and French translation files
|
||||
- Use descriptive, hierarchical keys
|
||||
- Keep translations consistent in tone
|
||||
4. **Locale-aware navigation**:
|
||||
|
||||
5. **Locale-aware navigation**:
|
||||
```tsx
|
||||
router.push(`/${params.locale}/settings`);
|
||||
```
|
||||
@@ -203,16 +200,11 @@ webmail/
|
||||
|
||||
## Security
|
||||
|
||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||
- **Never commit secrets** - API keys, passwords, tokens, `.env*` files
|
||||
- **Sanitize user input** and email content
|
||||
- **Block external content** by default for privacy
|
||||
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
|
||||
- **Block external content** by default - privacy is the point
|
||||
- **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about contributing, feel free to:
|
||||
|
||||
- Open an issue for discussion
|
||||
- Check existing issues and pull requests
|
||||
|
||||
Thank you for helping improve Bulwark Webmail!
|
||||
Open an issue, search existing ones, or ask in Discord. Thanks for helping build the webmail we all wished existed.
|
||||
|
||||
+9
-1
@@ -4,6 +4,14 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Optional: serve under a subpath like /webmail. Baked into emitted asset URLs
|
||||
# at build time, so it cannot be changed without rebuilding.
|
||||
ARG NEXT_PUBLIC_BASE_PATH=
|
||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
|
||||
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||
ARG GIT_COMMIT=unknown
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
RUN npx next build --webpack
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
@@ -26,7 +34,7 @@ RUN apk upgrade --no-cache && \
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
RUN mkdir -p /app/data/settings /app/data/admin && chown -R nextjs:nodejs /app/data
|
||||
RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
# Features
|
||||
|
||||
## Mail
|
||||
|
||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
||||
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||
- Unified mailbox view across all connected accounts
|
||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
||||
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
||||
- Attachment upload, download, drag-out to local file system, and inline preview; image thumbnails and forgotten-attachment warning
|
||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||
- Batch operations – multi-select, archive, delete, move, tag
|
||||
- Archive modes – direct, by year, or by month
|
||||
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
|
||||
- Star/unstar with configurable mark-as-read delay
|
||||
- Virtual scrolling for large mailboxes plus prefetching of initial email data on login
|
||||
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
||||
- Plain-text composer mode and Reply-To support
|
||||
- Configurable signature position (above or below quoted text) per identity
|
||||
- From-header override in the composer with optional catch-all auto-reply: replies to an alias on a domain you own auto-fill the alias as the sender even when it isn't a configured identity
|
||||
- `.eml` file import via folder right-click menu
|
||||
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
||||
- Folder management with icon picker, subfolders, and sidebar counts
|
||||
- Print directly from the viewer
|
||||
- Browser history sync for back/forward navigation
|
||||
|
||||
## Calendar
|
||||
|
||||
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
|
||||
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
|
||||
- Recurring events with scoped edit/delete (this / this and following / all)
|
||||
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
|
||||
- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import
|
||||
- iCalendar import with preview, bulk create, and UID deduplication
|
||||
- iCal / webcal subscriptions with editing and batch import
|
||||
- Auto-generated birthday calendar from contacts
|
||||
- Virtual locations (video conference URLs) as first-class event fields
|
||||
- Task management with due dates, priority, and completion status
|
||||
- Shared calendars with CalDAV discovery and multi-account home resolution
|
||||
- Week numbers, event hover preview, notifications with sound picker
|
||||
- Real-time sync via JMAP push
|
||||
|
||||
## Contacts
|
||||
|
||||
- JMAP sync (RFC 9553 / 9610) with local fallback
|
||||
- Multiple address books with drag-and-drop between books
|
||||
- Contact groups with member management
|
||||
- vCard import/export (RFC 6350) with duplicate detection
|
||||
- Trusted senders stored in a dedicated JMAP address book
|
||||
- Autocomplete in the composer (To / Cc / Bcc)
|
||||
|
||||
## Filters & Templates
|
||||
|
||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
||||
- Preserves rules authored in other clients
|
||||
- Raw Sieve editor with syntax validation
|
||||
- Vacation responder with date range scheduling
|
||||
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
||||
|
||||
## Files
|
||||
|
||||
- JMAP FileNode browser (Stalwart native cloud storage)
|
||||
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
||||
- Dynamic upload limits based on server configuration
|
||||
- Grid and list views with sorting by name, size, or date
|
||||
- Previews for images, text, audio, and video
|
||||
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
- External content blocked by default, with a trusted senders list
|
||||
- HTML sanitization via DOMPurify
|
||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
||||
- SPF / DKIM / DMARC status indicators
|
||||
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||
- TOTP two-factor authentication
|
||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
||||
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
|
||||
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
|
||||
- Plugin hardening with dangerous-pattern detection and admin approval
|
||||
- Newsletter unsubscribe (RFC 2369)
|
||||
|
||||
## Interface
|
||||
|
||||
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
|
||||
- Dark and light themes with intelligent email color transformation
|
||||
- Responsive desktop, tablet, and mobile layouts
|
||||
- Full keyboard navigation
|
||||
- Drag-and-drop email organization and tag assignment
|
||||
- Interactive guided tour for new users
|
||||
- Right-click context menus, toast notifications with undo
|
||||
- Customizable toolbar position, favicon, and login branding
|
||||
- Pinnable sidebar apps with drag-and-drop reordering
|
||||
- Encrypted settings sync across devices
|
||||
- Storage quota display
|
||||
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions
|
||||
|
||||
## Internationalization
|
||||
|
||||
17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
## Identity & Multi-Account
|
||||
|
||||
- Multiple simultaneous accounts with instant switching and per-account session persistence; the 5-account cap is lifted on HTTP/2 servers (limited by browser connection pooling on HTTP/1.1)
|
||||
- Account switcher with connection status and default account selection
|
||||
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
||||
- Configurable signature position (above or below quoted text)
|
||||
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
||||
- Shared folders across accounts
|
||||
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
|
||||
## Admin & Extensibility
|
||||
|
||||
- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
|
||||
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
||||
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
|
||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs, and managed policy enforcement
|
||||
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
|
||||
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
|
||||
- Bundled plugins including Jitsi Meet calendar integration
|
||||
|
||||
## Operations
|
||||
|
||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, and dynamic manifest
|
||||
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
|
||||
- Structured logging (`text` or `json`) with category-based levels
|
||||
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) – version, platform, bucketed account counts, feature toggles only
|
||||
- Release (`main`) and development (`dev`) Docker images on GHCR
|
||||
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
|
||||
- Demo mode with fixture data – no mail server required
|
||||
@@ -8,309 +8,155 @@
|
||||
|
||||
# Bulwark Webmail
|
||||
|
||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/>
|
||||
Built with Next.js and the JMAP protocol.
|
||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Installer
|
||||
|
||||
New in **1.6.4**: a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container.
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="screenshots/installer-dark.png" />
|
||||
<img src="screenshots/installer.png" alt="Setup wizard" width="100%" />
|
||||
</picture>
|
||||
|
||||
Point a browser at the running container and the wizard guides you through:
|
||||
|
||||
- **Server** – probe one or more JMAP endpoints, optional auto-pick by email domain, Stalwart feature toggle
|
||||
- **Auth** – OAuth2 / OIDC discovery and validation, or basic-auth fallback
|
||||
- **Security** – generate or paste a `SESSION_SECRET`, opt into settings sync
|
||||
- **Logging** – text or JSON, level
|
||||
- **Branding** – upload favicon, app logos, login logos, and company / legal URLs
|
||||
- **Review** – grouped summary with an advanced toggle for the full config
|
||||
- **Admin** – set the initial admin password and optionally drop a `.config-locked` marker so the config volume can be remounted read-only
|
||||
|
||||
The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JMAP_SERVER_URL` in the environment skips the wizard and uses env-managed configuration instead.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="screenshots/mail-dark.png" />
|
||||
<img src="screenshots/mail-white.png" alt="Mail view" width="100%" />
|
||||
</picture>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
|
||||
|
||||
**Mail** - Three-pane layout with sidebar, email list, and viewer
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
||||
|
||||
**Calendar** - Month, week, day, and agenda views with event management
|
||||
|
||||
</td>
|
||||
<td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
|
||||
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
||||
|
||||
**Contacts** - Contact management with groups and vCard support
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/files.png" width="100%" alt="File browser">
|
||||
|
||||
**Files** - Cloud file browser with upload, preview, and folder navigation
|
||||
|
||||
</td>
|
||||
<td><sub><b>Calendar</b> – month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.</sub></td>
|
||||
<td><sub><b>Contacts</b> – multiple address books, groups, vCard import/export, and autocomplete in the composer.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/theme.png" alt="Themes" /></td>
|
||||
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sub><b>Themes</b> – bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
|
||||
<td><sub><b>Plugins</b> – extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
|
||||
<td><img src="screenshots/settings.png" alt="Settings" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sub><b>Light mode</b> – full theme support with intelligent color transformation for HTML emails.</sub></td>
|
||||
<td><sub><b>Settings</b> – appearance, identities, filters, templates, security, and more.</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<details>
|
||||
<summary>More screenshots</summary>
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
## Overview
|
||||
|
||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
|
||||
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||
|
||||
**Light mode** - Full theme support with intelligent color transformation
|
||||
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
||||
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||
- **Contacts** – multiple address books, groups, vCard import/export
|
||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
|
||||
|
||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
||||
|
||||
**Settings** - Appearance, identities, filters, templates, and more
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/login.png" width="100%" alt="Login page">
|
||||
|
||||
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</details>
|
||||
|
||||
## Features
|
||||
|
||||
### Mail
|
||||
|
||||
- **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding)
|
||||
- **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle
|
||||
- **Unified mailbox** - view emails across all accounts in a single list
|
||||
- **Draft auto-save** with discard confirmation; full draft editing with identity preservation
|
||||
- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten
|
||||
- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
||||
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
|
||||
- **Archive modes** - archive directly or organize archived mail by year or month
|
||||
- **Print** emails directly from the viewer
|
||||
- **Answered/forwarded status icons** in email list and thread views
|
||||
- **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment
|
||||
- **Star/unstar** with configurable mark-as-read delay
|
||||
- **Virtual scrolling** for large mailboxes
|
||||
- **Quick reply** from the viewer
|
||||
- **Hover actions** - configurable quick-action buttons on email rows with customizable placement
|
||||
- **Sender avatars** - favicon-based with negative caching for performance
|
||||
- **Recipient popover** for quick contact interaction
|
||||
- **Plain text composer mode** and auto-select reply identity
|
||||
- **Reply-to addresses** support in the composer
|
||||
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
|
||||
- **message/rfc822 unwrapping** for embedded messages
|
||||
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
|
||||
- **Tag counts** - unread and total counts displayed in sidebar
|
||||
- **Browser history sync** - back/forward navigation mirrors mail view state
|
||||
|
||||
### Calendar
|
||||
|
||||
- **Month, week, day, and agenda views** with mini-calendar sidebar and a dedicated task list view
|
||||
- **Event hover preview** popover with configurable details
|
||||
- **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap)
|
||||
- **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion
|
||||
- **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment
|
||||
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
|
||||
- **iCalendar import** with preview, bulk create, and UID deduplication
|
||||
- **iCal / webcal subscriptions** with editing and batch import
|
||||
- **Birthday calendar** - auto-generated from contacts
|
||||
- **Virtual locations** - video conference URLs as first-class event fields
|
||||
- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird)
|
||||
- **Shared calendars** with visual grouping in the sidebar
|
||||
- **CalDAV discovery** with automatic calendar home resolution for multi-account setups
|
||||
- **Week numbers** in mini-calendar sidebar
|
||||
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
|
||||
- **Real-time sync** via JMAP push
|
||||
|
||||
### Contacts
|
||||
|
||||
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
|
||||
- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form
|
||||
- **Collapsible sidebar** with address book grouping and bulk operations
|
||||
- **Contact groups** with group expansion and member management
|
||||
- **vCard import/export** (RFC 6350) with duplicate detection
|
||||
- **Trusted senders** stored in a dedicated JMAP address book
|
||||
- **Autocomplete** in composer (To/Cc/Bcc)
|
||||
- **Bulk operations** - multi-select, delete, group add, export
|
||||
|
||||
### Filters & Automation
|
||||
|
||||
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
||||
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view
|
||||
- **External rule preservation** - rules authored in other clients are displayed and preserved
|
||||
- **Raw Sieve editor** with syntax validation
|
||||
- **Vacation responder** with date range scheduling and sidebar indicator
|
||||
- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
||||
|
||||
### Files
|
||||
|
||||
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
|
||||
- **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering)
|
||||
- **Dynamic upload limits** - respects the server-configured maximum upload size
|
||||
- **Folder navigation** with breadcrumb path and tree sidebar
|
||||
- **Grid and list views** with sorting by name, size, or date
|
||||
- **Clipboard operations** - cut, copy, paste, duplicate files
|
||||
- **File preview** for images, text, audio, video, and more
|
||||
- **Favorites and recent files** for quick access
|
||||
- **Bulk operations** - multi-select, delete, move, download
|
||||
|
||||
### Security & Privacy
|
||||
|
||||
- **External content blocked** by default - trusted senders list for auto-load
|
||||
- **HTML sanitization** via DOMPurify with XSS prevention
|
||||
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation
|
||||
- **SPF/DKIM/DMARC** status indicators
|
||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments
|
||||
- **TOTP two-factor authentication**
|
||||
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
|
||||
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
|
||||
- **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
|
||||
- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
|
||||
- **Newsletter unsubscribe** (RFC 2369)
|
||||
|
||||
### Interface
|
||||
|
||||
- **Three-pane layout** - sidebar, email list, viewer with resizable columns
|
||||
- **Dark and light themes** with intelligent email color transformation
|
||||
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
||||
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
|
||||
- **Keyboard shortcuts** - full navigation without a mouse
|
||||
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
||||
- **Interactive guided tour** - onboarding walkthrough for new users
|
||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
||||
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
||||
- **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes
|
||||
- **Settings sync** - preferences synchronized with the server (encrypted)
|
||||
- **Storage quota** display
|
||||
- **Version badge** in settings
|
||||
- **Focused mode** with proper viewport bounds
|
||||
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||
|
||||
### Internationalization
|
||||
|
||||
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
### Identity Management
|
||||
|
||||
- **Multiple sender identities** with per-identity signatures
|
||||
- **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes
|
||||
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
|
||||
- **Identity badges** in viewer and email list
|
||||
|
||||
### Multi-Account
|
||||
|
||||
- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence
|
||||
- **Account switcher** with connection status, default account selection, and per-account logout
|
||||
- **Per-account settings** - encrypted settings storage with server-side sync
|
||||
- **Shared folders** across accounts
|
||||
- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
|
||||
### Admin & Extensibility
|
||||
|
||||
- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections
|
||||
- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement
|
||||
- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles
|
||||
- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
||||
- **Bundled plugins** - Jitsi Meet calendar integration
|
||||
|
||||
### Operations
|
||||
|
||||
- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
|
||||
- **Automatic update check** - server logs when a newer release is available
|
||||
- **Logging categories** with `text` or `json` formats for log aggregation
|
||||
- **Docker images** - release (`main`) and development (`dev`) channels on GHCR
|
||||
- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required
|
||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker (recommended)
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e JMAP_SERVER_URL=https://mail.example.com \
|
||||
ghcr.io/bulwarkmail/webmail:latest
|
||||
docker run -d -p 3000:3000 ghcr.io/bulwarkmail/webmail:latest
|
||||
```
|
||||
|
||||
Or with Docker Compose:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local - set JMAP_SERVER_URL
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
On first launch, open `http://localhost:3000` – the **web setup wizard** walks you through JMAP server, OAuth, branding, and the admin password. No `.env.local` editing required. Existing installs that already define `JMAP_SERVER_URL` in their environment skip the wizard and keep the env-managed flow described under [Configuration](#configuration).
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bulwarkmail/webmail.git
|
||||
cd webmail
|
||||
npm install
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local - set JMAP_SERVER_URL
|
||||
npm run build && npm start
|
||||
# Then open http://localhost:3000 to run the setup wizard
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server (mock JMAP server included)
|
||||
npm run typecheck # Type checking
|
||||
npm run lint # Linting
|
||||
npm run dev # Dev server with a mock JMAP server
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `.env.local`:
|
||||
Most deployments are configured through the **setup wizard** (on first launch) and the **admin dashboard** thereafter; values are written to the admin config directory rather than `.env.local`. Environment variables remain supported for operators who prefer file-driven configuration or read-only / immutable infrastructure. When an environment variable is set, it takes precedence over the corresponding admin-managed value, so setting `JMAP_SERVER_URL` will hide that field from the wizard and lock it in the admin UI.
|
||||
|
||||
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
|
||||
|
||||
```env
|
||||
# Required
|
||||
# Optional – overrides whatever the wizard writes
|
||||
JMAP_SERVER_URL=https://mail.example.com
|
||||
|
||||
# Optional
|
||||
APP_NAME=My Webmail
|
||||
```
|
||||
|
||||
All variables are **runtime** - Docker deployments can be configured without rebuilding.
|
||||
|
||||
<details>
|
||||
<summary>Server Listen Address</summary>
|
||||
<summary>Server listen address</summary>
|
||||
|
||||
```env
|
||||
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
|
||||
PORT=3000 # Default listen port
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>OAuth2/OIDC (SSO)</summary>
|
||||
<summary>OAuth2 / OIDC</summary>
|
||||
|
||||
```env
|
||||
OAUTH_ENABLED=true
|
||||
OAUTH_CLIENT_ID=webmail
|
||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
|
||||
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
||||
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
||||
OAUTH_ISSUER_URL= # optional, for external IdPs
|
||||
```
|
||||
|
||||
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
|
||||
@@ -318,29 +164,28 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Remember Me & Settings Sync</summary>
|
||||
<summary>Session & settings sync</summary>
|
||||
|
||||
```env
|
||||
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
||||
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
|
||||
SESSION_SECRET= # openssl rand -base64 32
|
||||
SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
|
||||
|
||||
SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server
|
||||
SETTINGS_DATA_DIR=./data/settings # Storage location (mount a volume in Docker)
|
||||
SETTINGS_SYNC_ENABLED=true
|
||||
SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker
|
||||
```
|
||||
|
||||
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
|
||||
Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
|
||||
Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Custom JMAP Endpoint</summary>
|
||||
<summary>Custom JMAP endpoint</summary>
|
||||
|
||||
```env
|
||||
ALLOW_CUSTOM_JMAP_ENDPOINT=true # Shows a "JMAP Server" field on login
|
||||
ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||
```
|
||||
|
||||
Lets users connect to any JMAP-compatible server. External servers must CORS-allow the webmail origin.
|
||||
Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -349,13 +194,13 @@ Lets users connect to any JMAP-compatible server. External servers must CORS-all
|
||||
|
||||
```env
|
||||
APP_NAME=My Webmail
|
||||
APP_SHORT_NAME=Webmail # Home-screen label on mobile
|
||||
APP_DESCRIPTION=Your personal mail # Shown during PWA install
|
||||
APP_SHORT_NAME=Webmail
|
||||
APP_DESCRIPTION=Your personal mail
|
||||
|
||||
FAVICON_URL=/branding/favicon.svg
|
||||
PWA_ICON_URL=/branding/icon.svg # Falls back to FAVICON_URL
|
||||
PWA_THEME_COLOR=#3b82f6 # Browser chrome color
|
||||
PWA_BACKGROUND_COLOR=#ffffff # PWA splash background
|
||||
PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
|
||||
PWA_THEME_COLOR=#3b82f6
|
||||
PWA_BACKGROUND_COLOR=#ffffff
|
||||
|
||||
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
|
||||
APP_LOGO_DARK_URL=/branding/logo-dark.svg
|
||||
@@ -371,7 +216,7 @@ LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Extension Directory</summary>
|
||||
<summary>Extension directory</summary>
|
||||
|
||||
```env
|
||||
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
@@ -382,18 +227,50 @@ Enables the admin marketplace for browsing and installing plugins and themes.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Stalwart Integration & Logging</summary>
|
||||
<summary>Stalwart integration & logging</summary>
|
||||
|
||||
```env
|
||||
STALWART_FEATURES=true # Password change, sieve filters, etc.
|
||||
STALWART_API_URL=https://admin.example.com # If reverse proxy doesn't forward /api/*
|
||||
STALWART_FEATURES=true # password change, Sieve filters, etc.
|
||||
|
||||
LOG_FORMAT=text # "text" or "json"
|
||||
LOG_LEVEL=info # "error", "warn", "info", "debug"
|
||||
LOG_LEVEL=info # error | warn | info | debug
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Admin data directories</summary>
|
||||
|
||||
```env
|
||||
ADMIN_CONFIG_DIR=./data/admin # operator-authored: config.json, policy.json, plugins/, themes/
|
||||
ADMIN_STATE_DIR=./data/admin-state # runtime: audit log, login timestamps, setup token
|
||||
ADMIN_CONFIG_READONLY=true # enforce read-only mode at the app layer
|
||||
```
|
||||
|
||||
The split lets you mount the config volume read-only after the setup wizard completes. Legacy installs that pre-date the split keep working through `ADMIN_DATA_DIR`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Subpath / reverse proxy mount</summary>
|
||||
|
||||
To serve the webmail at a subpath (e.g. `https://example.com/webmail`):
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_BASE_PATH=/webmail
|
||||
NEXT_PUBLIC_LOCALE_PREFIX=always # avoids next-intl rewrite loops
|
||||
```
|
||||
|
||||
Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** because Next.js bakes it into emitted asset URLs. To use it with the published Docker image, build your own image with the variable set:
|
||||
|
||||
```bash
|
||||
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
|
||||
```
|
||||
|
||||
Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly.
|
||||
|
||||
</details>
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
@@ -424,19 +301,16 @@ LOG_LEVEL=info # "error", "warn", "info", "debug"
|
||||
|
||||
## Why Stalwart?
|
||||
|
||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a Rust mail server with native JMAP support – not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
[GNU AGPL v3](LICENSE)
|
||||
|
||||
This repository also preserves the original MIT attribution notice for the
|
||||
fork lineage in [NOTICE](NOTICE).
|
||||
[GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for doing most of the groundwork that this project builds upon.
|
||||
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for the groundwork this project builds upon.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
// Catch-all that anchors unmatched URLs into the (main) route group so
|
||||
// Next renders app/(main)/not-found.tsx (wrapped by (main)/layout.tsx)
|
||||
// instead of the built-in __next_builtin__not-found page. Without this,
|
||||
// route groups can't pick a root layout for URLs that match nothing, so
|
||||
// 404s render bare.
|
||||
export default function CatchAll() {
|
||||
notFound();
|
||||
}
|
||||
@@ -43,6 +43,7 @@ function OAuthCallbackInner() {
|
||||
|
||||
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
||||
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
||||
const serverId = sessionStorage.getItem("oauth_server_id") || undefined;
|
||||
|
||||
if (!codeVerifier || !serverUrl) {
|
||||
setError("missing_params");
|
||||
@@ -52,12 +53,13 @@ function OAuthCallbackInner() {
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||
|
||||
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
|
||||
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId)
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
sessionStorage.removeItem("oauth_state");
|
||||
sessionStorage.removeItem("oauth_code_verifier");
|
||||
sessionStorage.removeItem("oauth_server_url");
|
||||
sessionStorage.removeItem("oauth_server_id");
|
||||
sessionStorage.removeItem("oauth_add_account_mode");
|
||||
let redirectTo = `${prefix}/${params.locale}`;
|
||||
try {
|
||||
@@ -76,7 +78,69 @@ function OAuthCallbackInner() {
|
||||
setError("token_exchange_failed");
|
||||
});
|
||||
} else if (state) {
|
||||
// Server-side SSO flow - state was stored in encrypted httpOnly cookie
|
||||
// Server-side SSO flow - state was stored in encrypted httpOnly cookie.
|
||||
// Branch on mobile handoff first: the login page left a marker in
|
||||
// sessionStorage if it kicked this OAuth dance off for the mobile app.
|
||||
let mobileRedirectUri: string | null = null;
|
||||
let mobileState: string | null = null;
|
||||
try {
|
||||
mobileRedirectUri = sessionStorage.getItem("mobile_redirect_uri");
|
||||
mobileState = sessionStorage.getItem("mobile_state");
|
||||
} catch { /* sessionStorage may be unavailable */ }
|
||||
|
||||
if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) {
|
||||
// Drive /api/auth/sso/complete directly so we can read the tokens
|
||||
// out of the response - loginWithServerSso would consume them and
|
||||
// wire up the webmail auth store, which isn't useful here. The
|
||||
// server's mobile-flow branch (keyed on the pending cookie) skips
|
||||
// the refresh-token cookie write for the same reason.
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/sso/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ code, state }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError("token_exchange_failed");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const serverUrl = data.server_url as string | undefined;
|
||||
const accessToken = data.access_token as string | undefined;
|
||||
const tokenEndpoint = data.token_endpoint as string | undefined;
|
||||
const clientId = data.client_id as string | undefined;
|
||||
if (!serverUrl || !accessToken || !tokenEndpoint || !clientId) {
|
||||
setError("token_exchange_failed");
|
||||
return;
|
||||
}
|
||||
const fragment = new URLSearchParams({
|
||||
flow: "oauth",
|
||||
server_url: serverUrl,
|
||||
access_token: accessToken,
|
||||
token_endpoint: tokenEndpoint,
|
||||
client_id: clientId,
|
||||
state: mobileState ?? "",
|
||||
});
|
||||
if (typeof data.refresh_token === "string") {
|
||||
fragment.set("refresh_token", data.refresh_token);
|
||||
}
|
||||
if (typeof data.expires_in === "number") {
|
||||
fragment.set("expires_in", String(data.expires_in));
|
||||
}
|
||||
try {
|
||||
sessionStorage.removeItem("mobile_redirect_uri");
|
||||
sessionStorage.removeItem("mobile_state");
|
||||
} catch { /* ignore */ }
|
||||
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
|
||||
} catch {
|
||||
setError("token_exchange_failed");
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||
loginWithServerSso(code, state)
|
||||
.then((success) => {
|
||||
@@ -15,8 +15,9 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
|
||||
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
||||
@@ -30,23 +31,38 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { downloadEventICS } from "@/lib/calendar-ics-export";
|
||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
||||
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
|
||||
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
||||
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
|
||||
type PendingScopeAction =
|
||||
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
|
||||
@@ -59,16 +75,25 @@ function isRecurringEvent(event: CalendarEvent): boolean {
|
||||
export default function CalendarPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("calendar");
|
||||
const tWebcalAction = useTranslations("calendar.webcal_action");
|
||||
const isMobile = useIsMobile();
|
||||
const isDesktop = useIsDesktop();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// When the pane (Pro shell) or window is narrower than `lg`, the sidebar
|
||||
// collapses into a burger-toggled overlay instead of taking inline space.
|
||||
const isNarrow = !isDesktop;
|
||||
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
|
||||
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const {
|
||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||
removeCalendar, clearCalendarEvents,
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
@@ -86,10 +111,20 @@ export default function CalendarPage() {
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [pendingSubscription, setPendingSubscription] = useState<{ url: string; name: string } | null>(null);
|
||||
const [showWebcalActionChoice, setShowWebcalActionChoice] = useState(false);
|
||||
const [pendingWebcalAccountChoice, setPendingWebcalAccountChoice] = useState<ParsedWebcal | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
|
||||
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
|
||||
const [showCreateCalendar, setShowCreateCalendar] = useState(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog();
|
||||
const tMgmt = useTranslations("calendar.management");
|
||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||
const [defaultModalAllDay, setDefaultModalAllDay] = useState(false);
|
||||
const [miniMonth, setMiniMonth] = useState(new Date());
|
||||
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
||||
@@ -122,8 +157,15 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [events, detailEvent]);
|
||||
|
||||
// Check auth on mount
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
@@ -133,10 +175,10 @@ export default function CalendarPage() {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
redirectToLogin();
|
||||
} else if (client && !supportsCalendar) {
|
||||
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]);
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -144,12 +186,95 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const getWebcalProtocolAccounts = useCallback(() => {
|
||||
const connectedClients = useAuthStore.getState().getAllConnectedClients();
|
||||
return useAccountStore.getState().accounts.filter((account) => {
|
||||
if (!account.isConnected) return false;
|
||||
return connectedClients.get(account.id)?.supportsCalendars() === true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openWebcalForAccount = useCallback(async (pending: ParsedWebcal, accountId: string) => {
|
||||
setIsProtocolAccountSwitching(true);
|
||||
try {
|
||||
if (useAuthStore.getState().activeAccountId !== accountId) {
|
||||
await switchAccount(accountId);
|
||||
}
|
||||
setPendingWebcalAccountChoice(null);
|
||||
setPendingSubscription({
|
||||
url: pending.subscriptionUrl,
|
||||
name: pending.suggestedName,
|
||||
});
|
||||
setShowWebcalActionChoice(true);
|
||||
} finally {
|
||||
setIsProtocolAccountSwitching(false);
|
||||
}
|
||||
}, [switchAccount]);
|
||||
|
||||
const handleWebcalProtocolRequest = useCallback((pending: ParsedWebcal) => {
|
||||
const protocolAccounts = getWebcalProtocolAccounts();
|
||||
if (protocolAccounts.length > 1) {
|
||||
setPendingWebcalAccountChoice(pending);
|
||||
return;
|
||||
}
|
||||
|
||||
if (protocolAccounts.length === 0 && !supportsCalendar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
|
||||
if (accountId) {
|
||||
void openWebcalForAccount(pending, accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingSubscription({
|
||||
url: pending.subscriptionUrl,
|
||||
name: pending.suggestedName,
|
||||
});
|
||||
setShowWebcalActionChoice(true);
|
||||
}, [activeAccountId, getWebcalProtocolAccounts, openWebcalForAccount, supportsCalendar]);
|
||||
|
||||
const closeWebcalActionChoice = useCallback(() => {
|
||||
setShowWebcalActionChoice(false);
|
||||
setPendingSubscription(null);
|
||||
}, []);
|
||||
|
||||
const handleImportWebcal = useCallback(() => {
|
||||
setShowWebcalActionChoice(false);
|
||||
setShowImportModal(true);
|
||||
}, []);
|
||||
|
||||
const handleSubscribeWebcal = useCallback(() => {
|
||||
setShowWebcalActionChoice(false);
|
||||
setShowSubscriptionModal(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
const openPendingWebcal = () => {
|
||||
const pending = consumePendingWebcal();
|
||||
if (!pending) return;
|
||||
|
||||
handleWebcalProtocolRequest(pending);
|
||||
};
|
||||
|
||||
openPendingWebcal();
|
||||
return subscribeToPendingWebcal(openPendingWebcal);
|
||||
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
|
||||
|
||||
// Single-account fetch path. The Pro shell aggregates calendars from
|
||||
// every connected account via [[useProMultiAccountCalendars]] below, so
|
||||
// skip this fetch there to avoid clobbering the merged list with the
|
||||
// active client's calendars only.
|
||||
useEffect(() => {
|
||||
if (isEmbedded) return;
|
||||
if (client && !hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
fetchCalendars(client);
|
||||
}
|
||||
}, [client, fetchCalendars]);
|
||||
}, [client, fetchCalendars, isEmbedded]);
|
||||
|
||||
// Auto-refresh iCal subscriptions
|
||||
useEffect(() => {
|
||||
@@ -161,9 +286,14 @@ export default function CalendarPage() {
|
||||
return () => clearInterval(interval);
|
||||
}, [client, refreshAllSubscriptions]);
|
||||
|
||||
// Auto-add birthday calendar to selected IDs when enabled
|
||||
// Auto-add birthday calendar to selected IDs only when the setting flips
|
||||
// off→on. Firing on every mount would undo a user's manual hide via the
|
||||
// sidebar each time they navigate back to the calendar (see #204).
|
||||
const prevShowBirthdayRef = useRef(showBirthdayCalendar);
|
||||
useEffect(() => {
|
||||
if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||
const wasShown = prevShowBirthdayRef.current;
|
||||
prevShowBirthdayRef.current = showBirthdayCalendar;
|
||||
if (!wasShown && showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
|
||||
}
|
||||
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -213,10 +343,21 @@ export default function CalendarPage() {
|
||||
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmbedded) return;
|
||||
if (client && calendars.length > 0 && dateRange) {
|
||||
fetchEvents(client, dateRange.start, dateRange.end);
|
||||
}
|
||||
}, [client, calendars.length, dateRange, fetchEvents]);
|
||||
}, [client, calendars.length, dateRange, fetchEvents, isEmbedded]);
|
||||
|
||||
// Pro shell only: aggregate calendars and events from every connected
|
||||
// account so the sidebar lists them all (and the views render their
|
||||
// events together). The hook is a no-op outside the embedded shell.
|
||||
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountCalendars(
|
||||
isEmbedded ? dateRange?.start ?? null : null,
|
||||
isEmbedded ? dateRange?.end ?? null : null,
|
||||
);
|
||||
const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars);
|
||||
const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents);
|
||||
|
||||
const navigatePrev = useCallback(() => {
|
||||
let next: Date;
|
||||
@@ -288,6 +429,8 @@ export default function CalendarPage() {
|
||||
setMobileReturnToMonth(true);
|
||||
setViewMode("day");
|
||||
}
|
||||
// Close the narrow-pane sidebar overlay after the user picks a date.
|
||||
setNarrowSidebarOpen(false);
|
||||
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
|
||||
|
||||
const navigateBackToMonth = useCallback(() => {
|
||||
@@ -300,11 +443,12 @@ export default function CalendarPage() {
|
||||
setSelectedDate(date);
|
||||
}, [setSelectedDate]);
|
||||
|
||||
const openCreateModal = useCallback((date?: Date, endDate?: Date) => {
|
||||
const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => {
|
||||
setEditEvent(null);
|
||||
const d = date || selectedDate;
|
||||
setDefaultModalDate(d);
|
||||
setDefaultModalEndDate(endDate);
|
||||
setDefaultModalAllDay(allDay ?? false);
|
||||
setSelectedDate(d);
|
||||
setShowEventModal(true);
|
||||
}, [selectedDate, setSelectedDate]);
|
||||
@@ -357,6 +501,33 @@ export default function CalendarPage() {
|
||||
openEditModal(event);
|
||||
}, [closeDetail, openEditModal]);
|
||||
|
||||
const {
|
||||
contextMenu: eventContextMenu,
|
||||
openContextMenu: openEventContextMenu,
|
||||
closeContextMenu: closeEventContextMenu,
|
||||
menuRef: eventContextMenuRef,
|
||||
} = useContextMenu<CalendarEvent>();
|
||||
|
||||
const handleContextMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
|
||||
closeDetail();
|
||||
openEventContextMenu(e, event);
|
||||
}, [closeDetail, openEventContextMenu]);
|
||||
|
||||
const {
|
||||
contextMenu: emptyContextMenu,
|
||||
openContextMenu: openEmptyContextMenu,
|
||||
closeContextMenu: closeEmptyContextMenu,
|
||||
menuRef: emptyContextMenuRef,
|
||||
} = useContextMenu<{ date: Date; hour?: number; allDayArea?: boolean }>();
|
||||
|
||||
const handleContextMenuEmpty = useCallback(
|
||||
(e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => {
|
||||
closeDetail();
|
||||
openEmptyContextMenu(e, { date, hour, allDayArea });
|
||||
},
|
||||
[closeDetail, openEmptyContextMenu],
|
||||
);
|
||||
|
||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||
if (isMobile) return;
|
||||
if (calendarHoverPreview === 'off') return;
|
||||
@@ -410,12 +581,32 @@ export default function CalendarPage() {
|
||||
}, [events, client]);
|
||||
|
||||
const refetchCurrentRange = useCallback(async () => {
|
||||
if (!client) return;
|
||||
if (!client || !activeAccountId) return;
|
||||
const { dateRange: currentRange } = useCalendarStore.getState();
|
||||
if (currentRange) {
|
||||
await fetchEvents(client, currentRange.start, currentRange.end);
|
||||
if (!currentRange) return;
|
||||
if (multiAccountEnabled && accountClients.length > 0) {
|
||||
await fetchAllAccountsEventsFn(accountClients, activeAccountId, currentRange.start, currentRange.end);
|
||||
return;
|
||||
}
|
||||
}, [client, fetchEvents]);
|
||||
await fetchEvents(client, currentRange.start, currentRange.end);
|
||||
}, [client, fetchEvents, multiAccountEnabled, accountClients, activeAccountId, fetchAllAccountsEventsFn]);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh calendar data via JMAP instead of reloading the page.
|
||||
useRefreshGesture({
|
||||
enabled: isAuthenticated && !!client,
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
|
||||
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
|
||||
: fetchCalendars(client);
|
||||
await Promise.all([
|
||||
calendarRefresh,
|
||||
refetchCurrentRange(),
|
||||
refreshAllSubscriptions(client),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
||||
if (!event.start) {
|
||||
@@ -732,6 +923,73 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [detailEvent, client, updateEvent, t]);
|
||||
|
||||
const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => {
|
||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||
const start = parseISO(event.start);
|
||||
const newStart = addDays(start, 1);
|
||||
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration: event.duration,
|
||||
timeZone: event.timeZone,
|
||||
showWithoutTime: event.showWithoutTime,
|
||||
calendarIds: { ...event.calendarIds },
|
||||
status: "confirmed",
|
||||
freeBusyStatus: event.freeBusyStatus,
|
||||
privacy: event.privacy,
|
||||
});
|
||||
if (event.locations) data.locations = structuredClone(event.locations);
|
||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||
if (event.participants) data.participants = structuredClone(event.participants);
|
||||
try {
|
||||
const created = await createEvent(client, data);
|
||||
if (created) {
|
||||
toast.success(t("notifications.event_duplicated"));
|
||||
openEditModal(created);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [client, createEvent, openEditModal, t]);
|
||||
|
||||
const handleExportICS = useCallback((event: CalendarEvent) => {
|
||||
try {
|
||||
downloadEventICS(event);
|
||||
toast.success(t("notifications.event_exported"));
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleCopyTitle = useCallback(async (event: CalendarEvent) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(event.title || "");
|
||||
toast.success(t("notifications.title_copied"));
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleCopyMeetingLink = useCallback(async (event: CalendarEvent) => {
|
||||
const uri = event.virtualLocations
|
||||
? Object.values(event.virtualLocations).find((v) => v.uri)?.uri
|
||||
: undefined;
|
||||
if (!uri) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(uri);
|
||||
toast.success(t("notifications.link_copied"));
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleDeleteContextMenu = useCallback((event: CalendarEvent) => {
|
||||
const hasParticipants = event.participants && Object.keys(event.participants).length > 0;
|
||||
handleDeleteEvent(event.id, hasParticipants || undefined);
|
||||
}, [handleDeleteEvent]);
|
||||
|
||||
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
|
||||
if (!detailEvent || !client) return;
|
||||
const participantId = getUserParticipantId(detailEvent, currentUserEmails);
|
||||
@@ -818,7 +1076,54 @@ export default function CalendarPage() {
|
||||
});
|
||||
}, [events, selectedCalendarIds, visibleEvents]);
|
||||
|
||||
if (!isAuthenticated || !supportsCalendar) return null;
|
||||
const renderWebcalAccountPicker = () => pendingWebcalAccountChoice ? (
|
||||
<ProtocolAccountPicker
|
||||
kind="webcal"
|
||||
operation={pendingWebcalAccountChoice}
|
||||
accounts={getWebcalProtocolAccounts()}
|
||||
activeAccountId={activeAccountId}
|
||||
isSwitching={isProtocolAccountSwitching}
|
||||
onSelect={(accountId) => void openWebcalForAccount(pendingWebcalAccountChoice, accountId)}
|
||||
onCancel={() => setPendingWebcalAccountChoice(null)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={closeWebcalActionChoice} aria-hidden="true" />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={tWebcalAction("title")}
|
||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">{tWebcalAction("title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{tWebcalAction("description", { name: pendingSubscription.name })}</p>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
|
||||
<span className="text-left">
|
||||
<span className="block font-medium">{tWebcalAction("import_title")}</span>
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
|
||||
<span className="text-left">
|
||||
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button variant="ghost" onClick={closeWebcalActionChoice}>{tWebcalAction("cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
if (!supportsCalendar) return renderWebcalAccountPicker();
|
||||
|
||||
const renderView = () => {
|
||||
if (isLoading && calendars.length === 0) {
|
||||
@@ -841,6 +1146,8 @@ export default function CalendarPage() {
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onContextMenuEmpty={handleContextMenuEmpty}
|
||||
onCreateAtTime={openCreateModal}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
isMobile={isMobile}
|
||||
@@ -857,6 +1164,8 @@ export default function CalendarPage() {
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onContextMenuEmpty={handleContextMenuEmpty}
|
||||
onCreateAtTime={openCreateModal}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
timeFormat={timeFormat}
|
||||
@@ -875,6 +1184,8 @@ export default function CalendarPage() {
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onContextMenuEmpty={handleContextMenuEmpty}
|
||||
onCreateAtTime={openCreateModal}
|
||||
timeFormat={timeFormat}
|
||||
isMobile={isMobile}
|
||||
@@ -892,6 +1203,7 @@ export default function CalendarPage() {
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
timeFormat={timeFormat}
|
||||
/>
|
||||
);
|
||||
@@ -938,9 +1250,11 @@ export default function CalendarPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Left Navigation Rail */}
|
||||
{!isMobile && (
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className={cn("relative flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Left Navigation Rail (hidden when embedded in Pro shell) */}
|
||||
{!isMobile && !isEmbedded && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -959,15 +1273,31 @@ export default function CalendarPage() {
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||
)}
|
||||
|
||||
{/* Sidebar - full height */}
|
||||
{!isMobile && !inlineApp && (
|
||||
{/* Narrow-pane backdrop: dim and close overlay sidebar */}
|
||||
{isNarrow && narrowSidebarOpen && !inlineApp && (
|
||||
<div
|
||||
className={cn(
|
||||
"inset-0 bg-black/50 z-40",
|
||||
isEmbedded ? "absolute" : "fixed"
|
||||
)}
|
||||
onClick={() => setNarrowSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar - in-flow when desktop pane, overlay when narrow */}
|
||||
{!inlineApp && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
||||
!isResizing && "transition-[width] duration-300"
|
||||
!isResizing && "transition-[width] duration-300",
|
||||
isNarrow && cn(
|
||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
!narrowSidebarOpen && "-translate-x-full"
|
||||
)
|
||||
)}
|
||||
style={{ width: `${calSidebarWidth}px` }}
|
||||
style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
|
||||
>
|
||||
<MiniCalendar
|
||||
selectedDate={selectedDate}
|
||||
@@ -989,11 +1319,49 @@ export default function CalendarPage() {
|
||||
}
|
||||
updateCalendar(client, calendarId, { color });
|
||||
} : undefined}
|
||||
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||
onCreateEvent={(cal: Calendar) => {
|
||||
setDefaultCalendarIdForCreate(cal.id);
|
||||
openCreateModal();
|
||||
}}
|
||||
onClearCalendar={client ? async (cal: Calendar) => {
|
||||
const ok = await confirmAction({
|
||||
title: tMgmt("clear_events"),
|
||||
message: tMgmt("confirm_clear", { name: cal.name }),
|
||||
variant: "destructive",
|
||||
confirmText: tMgmt("clear_events"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const count = await clearCalendarEvents(client, cal.id);
|
||||
toast.success(tMgmt("events_cleared", { count }));
|
||||
} catch {
|
||||
toast.error(tMgmt("error_clear"));
|
||||
}
|
||||
} : undefined}
|
||||
onDeleteCalendar={client ? async (cal: Calendar) => {
|
||||
const ok = await confirmAction({
|
||||
title: tMgmt("delete"),
|
||||
message: tMgmt("confirm_delete", { name: cal.name }),
|
||||
variant: "destructive",
|
||||
confirmText: tMgmt("delete"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await removeCalendar(client, cal.id);
|
||||
toast.success(tMgmt("calendar_deleted"));
|
||||
} catch {
|
||||
toast.error(tMgmt("error_delete"));
|
||||
}
|
||||
} : undefined}
|
||||
onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined}
|
||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||
client={client}
|
||||
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||
/>
|
||||
</div>
|
||||
{!isNarrow && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||
@@ -1003,6 +1371,7 @@ export default function CalendarPage() {
|
||||
}}
|
||||
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1024,6 +1393,7 @@ export default function CalendarPage() {
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
enableCalendarTasks={enableCalendarTasks}
|
||||
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -1052,11 +1422,13 @@ export default function CalendarPage() {
|
||||
calendars={calendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultAllDay={defaultModalAllDay}
|
||||
defaultCalendarId={defaultCalendarIdForCreate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||
onPreviewChange={setPendingPreview}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={false}
|
||||
@@ -1094,7 +1466,7 @@ export default function CalendarPage() {
|
||||
)}
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
{isMobile && (
|
||||
{isMobile && !isEmbedded && (
|
||||
<div className="shrink-0">
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
@@ -1106,6 +1478,54 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventContextMenu.data && (
|
||||
<EventContextMenu
|
||||
event={eventContextMenu.data}
|
||||
position={eventContextMenu.position}
|
||||
isOpen={eventContextMenu.isOpen}
|
||||
onClose={closeEventContextMenu}
|
||||
menuRef={eventContextMenuRef}
|
||||
onEdit={() => openEditModal(eventContextMenu.data!)}
|
||||
onDuplicate={() => handleDuplicateContextMenu(eventContextMenu.data!)}
|
||||
onExportICS={() => handleExportICS(eventContextMenu.data!)}
|
||||
onCopyTitle={() => handleCopyTitle(eventContextMenu.data!)}
|
||||
onCopyMeetingLink={() => handleCopyMeetingLink(eventContextMenu.data!)}
|
||||
onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{emptyContextMenu.data && (() => {
|
||||
const { date, hour } = emptyContextMenu.data;
|
||||
return (
|
||||
<EmptySpaceContextMenu
|
||||
position={emptyContextMenu.position}
|
||||
isOpen={emptyContextMenu.isOpen}
|
||||
onClose={closeEmptyContextMenu}
|
||||
menuRef={emptyContextMenuRef}
|
||||
onNewEvent={() => {
|
||||
const d = new Date(date);
|
||||
if (typeof hour === "number") {
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
} else {
|
||||
const now = new Date();
|
||||
d.setHours(now.getHours() + 1, 0, 0, 0);
|
||||
}
|
||||
openCreateModal(d);
|
||||
}}
|
||||
onNewAllDayEvent={() => {
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
openCreateModal(d, undefined, true);
|
||||
}}
|
||||
onNewTask={enableCalendarTasks ? () => {
|
||||
setEditTask(null);
|
||||
setShowTaskModal(true);
|
||||
} : undefined}
|
||||
onGoToToday={goToToday}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{detailEvent && detailAnchorRect && (
|
||||
<EventDetailPopover
|
||||
event={detailEvent}
|
||||
@@ -1132,11 +1552,13 @@ export default function CalendarPage() {
|
||||
calendars={calendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultAllDay={defaultModalAllDay}
|
||||
defaultCalendarId={defaultCalendarIdForCreate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={true}
|
||||
/>
|
||||
@@ -1146,14 +1568,23 @@ export default function CalendarPage() {
|
||||
<ICalImportModal
|
||||
calendars={calendars}
|
||||
client={client}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
initialUrl={pendingSubscription?.url}
|
||||
onClose={() => {
|
||||
setShowImportModal(false);
|
||||
setPendingSubscription(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSubscriptionModal && client && (
|
||||
<ICalSubscriptionModal
|
||||
client={client}
|
||||
onClose={() => setShowSubscriptionModal(false)}
|
||||
initialUrl={pendingSubscription?.url}
|
||||
initialName={pendingSubscription?.name}
|
||||
onClose={() => {
|
||||
setShowSubscriptionModal(false);
|
||||
setPendingSubscription(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1170,12 +1601,42 @@ export default function CalendarPage() {
|
||||
})()}
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
{renderWebcalAccountPicker()}
|
||||
{renderWebcalActionChoice()}
|
||||
<RecurrenceScopeDialog
|
||||
isOpen={!!pendingScopeAction}
|
||||
actionType={pendingScopeAction?.type || "edit"}
|
||||
onSelect={handleScopeSelect}
|
||||
onClose={() => setPendingScopeAction(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
|
||||
{showCreateCalendar && client && (
|
||||
<CreateCalendarModal
|
||||
client={client}
|
||||
onClose={() => setShowCreateCalendar(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sharingCalendarId && client && (() => {
|
||||
const cal = allCalendars.find((c) => c.id === sharingCalendarId);
|
||||
if (!cal) return null;
|
||||
return (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="calendar"
|
||||
collectionName={cal.name}
|
||||
shareWith={cal.shareWith}
|
||||
ownAccountId={client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
|
||||
}}
|
||||
onClose={() => setSharingCalendarId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowLeft, Users } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { ArrowLeft, Users, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
@@ -15,18 +17,24 @@ import { ContactsSidebar, type ContactCategory } from "@/components/contacts/con
|
||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn, generateUUID } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
|
||||
type View =
|
||||
| "list"
|
||||
@@ -40,6 +48,7 @@ type View =
|
||||
|
||||
export default function ContactsPage() {
|
||||
const t = useTranslations("contacts");
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
@@ -74,6 +83,8 @@ export default function ContactsPage() {
|
||||
bulkAddToGroup,
|
||||
moveContactToAddressBook,
|
||||
renameAddressBook,
|
||||
removeAddressBook,
|
||||
shareAddressBook,
|
||||
renameKeyword,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
@@ -82,11 +93,28 @@ export default function ContactsPage() {
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
|
||||
const [returnToEmail, setReturnToEmail] = useState(false);
|
||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const isMobile = useIsMobile();
|
||||
const isDesktop = useIsDesktop();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// One-shot intent flag: only consume the URL params on the first render that
|
||||
// has them. After applying, we strip the query so a later refresh or
|
||||
// re-mount doesn't re-trigger the navigation.
|
||||
const intentAppliedRef = useRef(false);
|
||||
// Narrow pane (Pro split or small window): the categories sidebar collapses
|
||||
// into a burger-toggled overlay.
|
||||
const isNarrow = !isDesktop;
|
||||
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
|
||||
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
|
||||
|
||||
// Panel resize state - sidebar (categories)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
@@ -97,13 +125,20 @@ export default function ContactsPage() {
|
||||
|
||||
// Panel resize state - contact list
|
||||
const [listWidth, setListWidth] = useState(() => {
|
||||
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; }
|
||||
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; }
|
||||
});
|
||||
const [isListResizing, setIsListResizing] = useState(false);
|
||||
const listDragStartWidth = useRef(320);
|
||||
const listDragStartWidth = useRef(384);
|
||||
|
||||
// Check auth on mount
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
@@ -116,12 +151,62 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
// Pro shell only: aggregate contacts and address books from every
|
||||
// connected account so the sidebar lists them all. The hook is a no-op
|
||||
// outside the embedded shell.
|
||||
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountContacts();
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmbedded) return;
|
||||
if (client && supportsSync && !hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
fetchContacts(client);
|
||||
}
|
||||
}, [client, supportsSync, fetchContacts]);
|
||||
}, [client, supportsSync, fetchContacts, isEmbedded]);
|
||||
|
||||
// Consume one-shot URL params (set by the mobile recipient popover when no
|
||||
// sidebar is available) and strip them so a refresh doesn't replay the
|
||||
// intent. `from=email` flips the mobile back button to `router.back()`.
|
||||
useEffect(() => {
|
||||
if (intentAppliedRef.current) return;
|
||||
const contactId = searchParams.get('contactId');
|
||||
const addEmail = searchParams.get('addEmail');
|
||||
const addName = searchParams.get('addName');
|
||||
const from = searchParams.get('from');
|
||||
if (!contactId && !addEmail && !from) return;
|
||||
intentAppliedRef.current = true;
|
||||
if (from === 'email') setReturnToEmail(true);
|
||||
if (contactId) {
|
||||
setSelectedContact(contactId);
|
||||
setView('detail');
|
||||
} else if (addEmail) {
|
||||
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
|
||||
setSelectedContact(null);
|
||||
setView('create');
|
||||
}
|
||||
router.replace('/contacts');
|
||||
}, [searchParams, router, setSelectedContact]);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh contacts via JMAP instead of reloading the page.
|
||||
useRefreshGesture({
|
||||
enabled: isAuthenticated && !!client && supportsSync,
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
if (multiAccountEnabled && accountClients.length > 0) {
|
||||
const activeId = useAuthStore.getState().activeAccountId;
|
||||
if (activeId) {
|
||||
const { fetchAllAccountsContacts, fetchAllAccountsAddressBooks } = useContactStore.getState();
|
||||
await Promise.all([
|
||||
fetchAllAccountsAddressBooks(accountClients, activeId),
|
||||
fetchAllAccountsContacts(accountClients, activeId),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await fetchContacts(client);
|
||||
},
|
||||
});
|
||||
|
||||
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
|
||||
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
||||
@@ -161,21 +246,6 @@ export default function ContactsPage() {
|
||||
return getGroupMembers(activeCategory.groupId);
|
||||
}, [activeCategory, individuals, getGroupMembers]);
|
||||
|
||||
// Label for the current category
|
||||
const categoryLabel = useMemo(() => {
|
||||
if (activeCategory === "all") return t("tabs.all");
|
||||
if (activeCategory === "uncategorized") return t("no_category");
|
||||
if ("addressBookId" in activeCategory) {
|
||||
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
||||
return book?.name || t("tabs.all");
|
||||
}
|
||||
if ("keyword" in activeCategory) {
|
||||
return activeCategory.keyword;
|
||||
}
|
||||
const group = contacts.find(c => c.id === activeCategory.groupId);
|
||||
return group ? getContactDisplayName(group) : t("tabs.all");
|
||||
}, [activeCategory, contacts, addressBooks, t]);
|
||||
|
||||
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
||||
setActiveCategory(category);
|
||||
clearSelection();
|
||||
@@ -185,6 +255,7 @@ export default function ContactsPage() {
|
||||
} else {
|
||||
setSelectedGroupId(null);
|
||||
}
|
||||
setNarrowSidebarOpen(false);
|
||||
}, [clearSelection]);
|
||||
|
||||
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
|
||||
@@ -248,9 +319,7 @@ export default function ContactsPage() {
|
||||
setView("edit");
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedContact) return;
|
||||
|
||||
const deleteContactById = useCallback(async (contactId: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("delete_confirm_title"),
|
||||
message: t("delete_confirm"),
|
||||
@@ -261,18 +330,60 @@ export default function ContactsPage() {
|
||||
|
||||
try {
|
||||
if (supportsSync && client) {
|
||||
await deleteContact(client, selectedContact.id);
|
||||
await deleteContact(client, contactId);
|
||||
} else {
|
||||
deleteLocalContact(selectedContact.id);
|
||||
deleteLocalContact(contactId);
|
||||
}
|
||||
toast.success(t("toast.deleted"));
|
||||
setView("list");
|
||||
if (selectedContactId === contactId) setView("list");
|
||||
} catch (error) {
|
||||
console.error('Failed to delete contact:', error);
|
||||
toast.error(t("toast.error_delete"));
|
||||
}
|
||||
}, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedContact) return;
|
||||
await deleteContactById(selectedContact.id);
|
||||
};
|
||||
|
||||
const handleEditContact = useCallback((id: string) => {
|
||||
setSelectedContact(id);
|
||||
setView("edit");
|
||||
}, [setSelectedContact]);
|
||||
|
||||
const handleDeleteContact = useCallback((contact: ContactCard) => {
|
||||
void deleteContactById(contact.id);
|
||||
}, [deleteContactById]);
|
||||
|
||||
const handleAddContactToGroup = useCallback((id: string) => {
|
||||
clearSelection();
|
||||
toggleContactSelection(id);
|
||||
if (groups.length === 0) {
|
||||
setView("group-create");
|
||||
return;
|
||||
}
|
||||
setView("bulk-add-to-group");
|
||||
}, [clearSelection, toggleContactSelection, groups.length]);
|
||||
|
||||
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
||||
const { id: _id, created: _created, updated: _updated, ...rest } = source;
|
||||
void _id; void _created; void _updated;
|
||||
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
toast.success(t("toast.created"));
|
||||
} else {
|
||||
const localContact: ContactCard = {
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: data.addressBookIds || {},
|
||||
...data,
|
||||
};
|
||||
addLocalContact(localContact);
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
|
||||
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
@@ -286,8 +397,15 @@ export default function ContactsPage() {
|
||||
addLocalContact(localContact);
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
setCreatePrefill(undefined);
|
||||
if (returnToEmail) {
|
||||
setReturnToEmail(false);
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
setView("list");
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
}, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]);
|
||||
|
||||
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
|
||||
if (!selectedContact) return;
|
||||
@@ -303,6 +421,15 @@ export default function ContactsPage() {
|
||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||
|
||||
const handleCancel = () => {
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
// Came from email → cancel returns to the email instead of the contact list.
|
||||
if (returnToEmail && view === "create") {
|
||||
setCreatePrefill(undefined);
|
||||
setReturnToEmail(false);
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
if (view === "create") setCreatePrefill(undefined);
|
||||
if (view === "group-create" || view === "group-edit") {
|
||||
setView(selectedGroup ? "group-detail" : "list");
|
||||
} else if (view === "bulk-add-to-group") {
|
||||
@@ -474,7 +601,7 @@ export default function ContactsPage() {
|
||||
const renderRightPanel = () => {
|
||||
switch (view) {
|
||||
case "create":
|
||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} prefill={createPrefill} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
|
||||
case "edit":
|
||||
if (!selectedContact) return null;
|
||||
@@ -574,24 +701,54 @@ export default function ContactsPage() {
|
||||
contact={selectedContact}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onAddToGroup={
|
||||
selectedContact
|
||||
? () => handleAddContactToGroup(selectedContact.id)
|
||||
: undefined
|
||||
}
|
||||
onDuplicate={
|
||||
selectedContact
|
||||
? () => void handleDuplicateContact(selectedContact)
|
||||
: undefined
|
||||
}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!contactsEnabled) {
|
||||
return (
|
||||
<div className="flex h-dvh items-center justify-center bg-background p-6">
|
||||
<div className="max-w-lg text-center space-y-3">
|
||||
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
|
||||
<p className="text-sm font-medium">Contacts feature is disabled by your administrator</p>
|
||||
<p className="text-xs text-muted-foreground">Please contact your administrator if you need access.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showListPanel = !isMobile || view === "list";
|
||||
const showRightPanel = !isMobile || view !== "list";
|
||||
|
||||
const mobileBackToList = () => {
|
||||
if (returnToEmail) {
|
||||
setReturnToEmail(false);
|
||||
setCreatePrefill(undefined);
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
setView("list");
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Navigation Rail - desktop only */}
|
||||
{!isMobile && (
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */}
|
||||
{!isMobile && !isEmbedded && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -610,18 +767,33 @@ export default function ContactsPage() {
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||
)}
|
||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
<div className={cn("relative flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
{/* Narrow-pane backdrop for the overlay categories sidebar */}
|
||||
{isNarrow && narrowSidebarOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
"inset-0 bg-black/50 z-40",
|
||||
isEmbedded ? "absolute" : "fixed"
|
||||
)}
|
||||
onClick={() => setNarrowSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{showListPanel && (
|
||||
<>
|
||||
{/* Panel 1: Categories sidebar */}
|
||||
{!isMobile && (
|
||||
{/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */}
|
||||
{(!isMobile || isNarrow) && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border flex flex-col flex-shrink-0",
|
||||
!isSidebarResizing && "transition-[width] duration-300"
|
||||
"border-r border-border flex flex-col flex-shrink-0 bg-background",
|
||||
!isSidebarResizing && "transition-[width] duration-300",
|
||||
isNarrow && cn(
|
||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
!narrowSidebarOpen && "-translate-x-full"
|
||||
)
|
||||
)}
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
style={isNarrow ? undefined : { width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<ContactsSidebar
|
||||
groups={groups}
|
||||
@@ -637,9 +809,31 @@ export default function ContactsPage() {
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
|
||||
onCreateContactInBook={(book) => {
|
||||
setDefaultBookIdForCreate(book.id);
|
||||
handleCreateNew();
|
||||
}}
|
||||
onDeleteAddressBook={client ? async (book) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("address_books.delete"),
|
||||
message: t("address_books.confirm_delete", { name: book.name }),
|
||||
variant: "destructive",
|
||||
confirmText: t("address_books.delete"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await removeAddressBook(client, book);
|
||||
toast.success(t("address_books.deleted"));
|
||||
} catch {
|
||||
toast.error(t("address_books.delete_failed"));
|
||||
}
|
||||
} : undefined}
|
||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||
/>
|
||||
</div>
|
||||
{!isNarrow && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||
@@ -649,6 +843,7 @@ export default function ContactsPage() {
|
||||
}}
|
||||
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -669,7 +864,6 @@ export default function ContactsPage() {
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCreateNew={handleCreateNew}
|
||||
categoryLabel={categoryLabel}
|
||||
className="flex-1"
|
||||
selectedContactIds={selectedContactIds}
|
||||
onToggleSelection={toggleContactSelection}
|
||||
@@ -679,6 +873,10 @@ export default function ContactsPage() {
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkAddToGroup={handleBulkAddToGroup}
|
||||
onBulkExport={handleBulkExport}
|
||||
onEditContact={handleEditContact}
|
||||
onDeleteContact={handleDeleteContact}
|
||||
onAddContactToGroup={handleAddContactToGroup}
|
||||
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -690,7 +888,7 @@ export default function ContactsPage() {
|
||||
setIsListResizing(false);
|
||||
localStorage.setItem("contacts-list-width", String(listWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }}
|
||||
onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -708,7 +906,7 @@ export default function ContactsPage() {
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t("back_to_contacts")}
|
||||
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -719,7 +917,7 @@ export default function ContactsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
{isMobile && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
@@ -783,6 +981,24 @@ export default function ContactsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sharingAddressBookId && client && (() => {
|
||||
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
|
||||
if (!book) return null;
|
||||
return (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="addressBook"
|
||||
collectionName={book.name}
|
||||
shareWith={book.shareWith}
|
||||
ownAccountId={client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
|
||||
}}
|
||||
onClose={() => setSharingAddressBookId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -16,13 +17,16 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { FileBrowser } from "@/components/files/file-browser";
|
||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
export default function FilesPage() {
|
||||
@@ -30,6 +34,9 @@ export default function FilesPage() {
|
||||
const t = useTranslations("files");
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const getClientForAccount = useAuthStore((s) => s.getClientForAccount);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
@@ -82,6 +89,7 @@ export default function FilesPage() {
|
||||
} = useFileStore();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
@@ -104,8 +112,15 @@ export default function FilesPage() {
|
||||
|
||||
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
|
||||
|
||||
// Check auth on mount
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
@@ -119,13 +134,27 @@ export default function FilesPage() {
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
// Initialize JMAP files client
|
||||
// Initialize JMAP files client. In the Pro shell, all connected accounts
|
||||
// are surfaced as top-level folders at the root, so we *don't* auto-attach
|
||||
// to the active account - the user picks one explicitly.
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && client && !hasFetched.current) {
|
||||
if (!isAuthenticated || !client || hasFetched.current) return;
|
||||
hasFetched.current = true;
|
||||
initClient(client);
|
||||
if (isEmbedded) {
|
||||
useFileStore.getState().clearClient();
|
||||
} else {
|
||||
initClient(client, activeAccountId);
|
||||
}
|
||||
}, [isAuthenticated, client, initClient]);
|
||||
}, [isAuthenticated, client, initClient, activeAccountId, isEmbedded]);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh files via JMAP instead of reloading the page.
|
||||
useRefreshGesture({
|
||||
enabled: isAuthenticated && !!client && supportsFiles === true,
|
||||
onRefresh: async () => {
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
|
||||
// Check support and load root after client is initialized
|
||||
const storeClient = useFileStore(s => s.client);
|
||||
@@ -140,6 +169,17 @@ export default function FilesPage() {
|
||||
}, [storeClient, supportsFiles, checkSupport, navigate]);
|
||||
|
||||
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
|
||||
// Pro shell only: the Account breadcrumb segment signals "go to this
|
||||
// account's filesystem root" via a sentinel, distinguishing it from a
|
||||
// Home click (which detaches the account and returns to the picker).
|
||||
if (resourceId === '__account_root__') {
|
||||
void navigate(null);
|
||||
return;
|
||||
}
|
||||
if (isEmbedded && path === '/' && resourceId === undefined) {
|
||||
useFileStore.getState().clearClient();
|
||||
return;
|
||||
}
|
||||
if (resourceId !== undefined) {
|
||||
// Direct ID-based navigation (directory click, breadcrumb dropdown folder)
|
||||
navigate(resourceId, path.split('/').pop() || '');
|
||||
@@ -147,7 +187,7 @@ export default function FilesPage() {
|
||||
// Path-based navigation (breadcrumbs, favorites, recent files)
|
||||
navigateByPath(path);
|
||||
}
|
||||
}, [navigate, navigateByPath]);
|
||||
}, [navigate, navigateByPath, isEmbedded]);
|
||||
|
||||
const handleCreateFolder = useCallback(async (name: string) => {
|
||||
try {
|
||||
@@ -354,11 +394,45 @@ export default function FilesPage() {
|
||||
setShowDetails(v => !v);
|
||||
}, []);
|
||||
|
||||
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
|
||||
|
||||
// Pro shell only: all connected accounts are equal top-level entries at
|
||||
// the root. The root path "/" itself is a cross-account picker - no
|
||||
// account's files are shown until the user enters one.
|
||||
const accountFolders = isEmbedded
|
||||
? accounts
|
||||
.filter((a) => a.isConnected)
|
||||
.map((a) => ({
|
||||
accountId: a.id,
|
||||
label: a.label || a.email,
|
||||
email: a.email,
|
||||
avatarColor: a.avatarColor,
|
||||
}))
|
||||
: [];
|
||||
const isAccountPicker = isEmbedded && currentFilesAccountId === null;
|
||||
const currentAccountLabel = isEmbedded && currentFilesAccountId
|
||||
? (accounts.find((a) => a.id === currentFilesAccountId)?.label
|
||||
|| accounts.find((a) => a.id === currentFilesAccountId)?.email
|
||||
|| null)
|
||||
: null;
|
||||
|
||||
const handleSelectAccount = useCallback((accountId: string) => {
|
||||
const nextClient = getClientForAccount(accountId);
|
||||
if (!nextClient) return;
|
||||
const store = useFileStore.getState();
|
||||
store.initClient(nextClient, accountId);
|
||||
// Reset supportsFiles so the existing checkSupport effect re-runs for
|
||||
// the freshly-attached client and triggers the initial navigate(null).
|
||||
useFileStore.setState({ supportsFiles: null });
|
||||
}, [getClientForAccount]);
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh bg-background overflow-hidden">
|
||||
{!isMobile && (
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{!isMobile && !isEmbedded && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -379,7 +453,7 @@ export default function FilesPage() {
|
||||
)}
|
||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{folderLayout !== "sidebar" && (
|
||||
{folderLayout !== "sidebar" && !isEmbedded && (
|
||||
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
@@ -457,6 +531,10 @@ export default function FilesPage() {
|
||||
showDetails={showDetails}
|
||||
onToggleDetails={handleToggleDetails}
|
||||
detailResource={detailResource}
|
||||
accountFolders={accountFolders}
|
||||
onSelectAccount={handleSelectAccount}
|
||||
accountPickerMode={isAccountPicker}
|
||||
accountLabel={currentAccountLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -464,7 +542,7 @@ export default function FilesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
{isMobile && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
@@ -498,5 +576,6 @@ export default function FilesPage() {
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import { CalendarAlertProvider } from "@/components/providers/calendar-alert-pro
|
||||
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
|
||||
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
||||
import { TourProvider } from "@/components/tour/tour-provider";
|
||||
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||
import { locales } from "@/i18n/routing";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -32,7 +36,12 @@ export default async function LocaleLayout({
|
||||
<RateLimitToastProvider>
|
||||
<EmbeddedBridgeProvider>
|
||||
<TourProvider>
|
||||
<ProtocolLaunchHandlerProvider>
|
||||
<ProInterfaceRedirect />
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<PluginConsentDialog />
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
</RateLimitToastProvider>
|
||||
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
@@ -15,7 +16,15 @@ import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
|
||||
|
||||
function findServerByDomain(servers: PublicJmapServerEntry[], email: string | undefined): PublicJmapServerEntry | undefined {
|
||||
if (!email || !email.includes("@")) return undefined;
|
||||
const domain = email.split("@")[1]?.trim().toLowerCase();
|
||||
if (!domain) return undefined;
|
||||
return servers.find((s) => (s.domains ?? []).some((d) => d.toLowerCase() === domain));
|
||||
}
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||
@@ -28,7 +37,12 @@ const THEME_OPTIONS = [
|
||||
|
||||
function VersionBadge() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
|
||||
const banner = useUpdateStore(useShallow(selectBanner));
|
||||
const startPolling = useUpdateStore((s) => s.startPolling);
|
||||
|
||||
useEffect(() => { startPolling(); }, [startPolling]);
|
||||
|
||||
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`;
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(versionInfo).then(() => {
|
||||
@@ -37,16 +51,49 @@ function VersionBadge() {
|
||||
});
|
||||
};
|
||||
|
||||
const isRed = banner?.variant === "red";
|
||||
const triggerText = !banner
|
||||
? `v${APP_VERSION}`
|
||||
: banner.severity === "security"
|
||||
? "Security update available"
|
||||
: banner.severity === "deprecated"
|
||||
? "Version no longer supported"
|
||||
: "New version available";
|
||||
|
||||
const triggerColor = !banner
|
||||
? "text-muted-foreground/40"
|
||||
: isRed
|
||||
? "text-red-600/80 dark:text-red-400/80 hover:text-red-600 dark:hover:text-red-400"
|
||||
: "text-amber-600/80 dark:text-amber-400/80 hover:text-amber-600 dark:hover:text-amber-400";
|
||||
|
||||
const triggerClass = cn(
|
||||
"peer text-center text-xs transition-colors",
|
||||
triggerColor,
|
||||
banner?.url ? "cursor-pointer underline-offset-2 hover:underline" : "cursor-default",
|
||||
);
|
||||
|
||||
const trigger = banner?.url ? (
|
||||
<a href={banner.url} target="_blank" rel="noopener noreferrer" className={triggerClass}>
|
||||
{triggerText}
|
||||
</a>
|
||||
) : (
|
||||
<p className={triggerClass}>{triggerText}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex justify-center">
|
||||
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
{trigger}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
|
||||
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
|
||||
{banner?.latest && (
|
||||
<p>Latest: <span className="font-medium">{banner.latest}</span></p>
|
||||
)}
|
||||
{banner?.advisory && (
|
||||
<p className="text-red-500 dark:text-red-400">{banner.advisory}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
@@ -61,15 +108,32 @@ function VersionBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
// Only redirect targets matching this scheme are honored by the mobile
|
||||
// handoff path. Without the check the login page becomes an open redirector
|
||||
// that funnels password and token material to any caller-supplied URL.
|
||||
const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("login");
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||
|
||||
// When the mobile app launches the webmail in a browser tab it tacks on
|
||||
// these params. We grab them once at mount and stash them in a ref so any
|
||||
// login path that completes (password or OAuth) can hand control back to
|
||||
// the app instead of routing into /mail.
|
||||
const rawMobileRedirectUri = searchParams.get("mobile_redirect_uri") ?? "";
|
||||
const rawMobileState = searchParams.get("mobile_state") ?? "";
|
||||
const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)
|
||||
? rawMobileRedirectUri
|
||||
: "";
|
||||
const mobileState = mobileRedirectUri ? rawMobileState : "";
|
||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig();
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -77,6 +141,18 @@ export default function LoginPage() {
|
||||
password: "",
|
||||
});
|
||||
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
|
||||
const [domainAutoLocked, setDomainAutoLocked] = useState(false);
|
||||
|
||||
const hasServerList = jmapServers.length > 0;
|
||||
const selectedServer = hasServerList
|
||||
? jmapServers.find((s) => s.id === selectedServerId) ?? jmapServers[0]
|
||||
: undefined;
|
||||
|
||||
// Effective values: per-server overrides win, then global config.
|
||||
const serverUrl = selectedServer?.url || configuredServerUrl;
|
||||
const effectiveOauthClientId = selectedServer?.oauth?.clientId || globalOauthClientId;
|
||||
const effectiveOauthIssuerUrl = selectedServer?.oauth?.issuerUrl || globalOauthIssuerUrl;
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [showTotpField, setShowTotpField] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
@@ -100,6 +176,9 @@ export default function LoginPage() {
|
||||
const totpInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevError = useRef<string | null>(null);
|
||||
const themeMenuRef = useRef<HTMLDivElement>(null);
|
||||
// Captured by handleSubmit when in mobile handoff mode; consumed by the
|
||||
// isAuthenticated effect to build the deep-link fragment.
|
||||
const mobileHandoffPayloadRef = useRef<{ server_url: string; username: string; password: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
initializeTheme();
|
||||
@@ -117,6 +196,27 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [serverUrl, jmapEndpoint]);
|
||||
|
||||
// Initialize selected server when the server list arrives. Picks the first
|
||||
// entry; the auto-pick effect below may override based on the email domain.
|
||||
useEffect(() => {
|
||||
if (!hasServerList) return;
|
||||
if (selectedServerId && jmapServers.some((s) => s.id === selectedServerId)) return;
|
||||
setSelectedServerId(jmapServers[0].id);
|
||||
}, [hasServerList, jmapServers, selectedServerId]);
|
||||
|
||||
// Auto-pick by email domain. Locks the dropdown to the matched server until
|
||||
// the user clears the email or types a domain we don't recognize.
|
||||
useEffect(() => {
|
||||
if (!jmapServerAutoPickByDomain || !hasServerList) return;
|
||||
const match = findServerByDomain(jmapServers, formData.username);
|
||||
if (match) {
|
||||
if (selectedServerId !== match.id) setSelectedServerId(match.id);
|
||||
setDomainAutoLocked(true);
|
||||
} else {
|
||||
setDomainAutoLocked(false);
|
||||
}
|
||||
}, [jmapServerAutoPickByDomain, hasServerList, jmapServers, formData.username, selectedServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (sessionStorage.getItem('session_expired') === 'true') {
|
||||
@@ -158,6 +258,19 @@ export default function LoginPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !isAddAccountMode) {
|
||||
// Mobile handoff: the password path completes here once the auth store
|
||||
// flips isAuthenticated. Hand the verified credentials back to the
|
||||
// mobile app instead of pushing to /mail. handleSubmit captured the
|
||||
// values needed for the fragment.
|
||||
if (isMobileHandoff && mobileHandoffPayloadRef.current) {
|
||||
const fragment = new URLSearchParams({
|
||||
flow: "password",
|
||||
...mobileHandoffPayloadRef.current,
|
||||
state: mobileState,
|
||||
});
|
||||
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
|
||||
return;
|
||||
}
|
||||
let redirectTo = '/';
|
||||
try {
|
||||
const saved = sessionStorage.getItem('redirect_after_login');
|
||||
@@ -168,7 +281,7 @@ export default function LoginPage() {
|
||||
} catch { /* ignore */ }
|
||||
router.push(redirectTo);
|
||||
}
|
||||
}, [isAuthenticated, router, isAddAccountMode]);
|
||||
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||
|
||||
useEffect(() => {
|
||||
clearError();
|
||||
@@ -214,7 +327,9 @@ export default function LoginPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!oauthEnabled || !serverUrl) return;
|
||||
discoverOAuth(oauthIssuerUrl || serverUrl)
|
||||
setOauthDiscoveryDone(false);
|
||||
setOauthMetadata(null);
|
||||
discoverOAuth(effectiveOauthIssuerUrl || serverUrl)
|
||||
.then((metadata) => {
|
||||
setOauthMetadata(metadata);
|
||||
setOauthDiscoveryDone(true);
|
||||
@@ -223,7 +338,7 @@ export default function LoginPage() {
|
||||
setOauthMetadata(null);
|
||||
setOauthDiscoveryDone(true);
|
||||
});
|
||||
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
|
||||
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]);
|
||||
|
||||
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||
const ssoError = searchParams.get("sso_error");
|
||||
@@ -234,11 +349,28 @@ export default function LoginPage() {
|
||||
try {
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||
// In mobile-handoff mode the callback page needs to know it should
|
||||
// redirect into the app rather than into /mail. Stash the params in
|
||||
// sessionStorage so the same-tab callback can read them - the SSO
|
||||
// pending cookie carries the authoritative copy server-side too.
|
||||
if (isMobileHandoff) {
|
||||
try {
|
||||
sessionStorage.setItem("mobile_redirect_uri", mobileRedirectUri);
|
||||
sessionStorage.setItem("mobile_state", mobileState);
|
||||
} catch { /* sessionStorage unavailable */ }
|
||||
}
|
||||
const res = await apiFetch('/api/auth/sso/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ redirect_uri: redirectUri, locale: params.locale }),
|
||||
body: JSON.stringify({
|
||||
redirect_uri: redirectUri,
|
||||
locale: params.locale,
|
||||
server_id: selectedServer?.id,
|
||||
...(isMobileHandoff
|
||||
? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -264,7 +396,7 @@ export default function LoginPage() {
|
||||
} catch {
|
||||
setOauthLoading(false);
|
||||
}
|
||||
}, [params.locale]);
|
||||
}, [params.locale, selectedServer?.id, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
||||
@@ -405,26 +537,56 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
if (!oauthMetadata || !oauthClientId) return;
|
||||
if (!oauthMetadata || !effectiveOauthClientId) return;
|
||||
// In mobile-handoff mode the client-side PKCE flow doesn't help us:
|
||||
// tokens would land in sessionStorage on the webmail origin and the
|
||||
// mobile app couldn't read them. Route through the server-side SSO
|
||||
// path instead, which has the mobile-aware /api/auth/sso/complete
|
||||
// branch.
|
||||
if (isMobileHandoff) {
|
||||
await startServerSideSso();
|
||||
return;
|
||||
}
|
||||
setOauthLoading(true);
|
||||
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
const state = generateState();
|
||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||
|
||||
// Resolve the JMAP URL to send to the callback. Server-list entries win
|
||||
// over the custom-endpoint input, which wins over the global server URL.
|
||||
const oauthServerUrl = selectedServer?.url
|
||||
|| (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl);
|
||||
|
||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||
sessionStorage.setItem("oauth_state", state);
|
||||
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
|
||||
sessionStorage.setItem("oauth_server_url", oauthServerUrl!);
|
||||
if (selectedServer?.id) {
|
||||
sessionStorage.setItem("oauth_server_id", selectedServer.id);
|
||||
} else {
|
||||
sessionStorage.removeItem("oauth_server_id");
|
||||
}
|
||||
if (isAddAccountMode) {
|
||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||
}
|
||||
|
||||
// Persist the next-free cookie slot so loginWithOAuth (in stores/auth-store.ts)
|
||||
// writes the refresh token to the correct per-account jmap_rt_<slot> cookie.
|
||||
// loginWithOAuth reads this key but it was previously never written, so every
|
||||
// OAuth account collapsed onto slot 0 and clobbered earlier accounts' refresh
|
||||
// tokens. getNextCookieSlot() returns 0 when no accounts exist (correct for
|
||||
// first sign-in) and the lowest unused slot otherwise (correct for "+ Add
|
||||
// Account").
|
||||
const nextSlot = useAccountStore.getState().getNextCookieSlot();
|
||||
sessionStorage.setItem("oauth_cookie_slot", nextSlot.toString());
|
||||
|
||||
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("client_id", oauthClientId);
|
||||
authUrl.searchParams.set("client_id", effectiveOauthClientId);
|
||||
authUrl.searchParams.set("redirect_uri", redirectUri);
|
||||
authUrl.searchParams.set("scope", OAUTH_SCOPES);
|
||||
authUrl.searchParams.set("scope", oauthScopes || "openid email profile");
|
||||
authUrl.searchParams.set("state", state);
|
||||
authUrl.searchParams.set("code_challenge", challenge);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
@@ -435,7 +597,20 @@ export default function LoginPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
|
||||
// Server-list entries always win - `allowCustomJmapEndpoint` is only honored
|
||||
// when the admin hasn't configured a server list.
|
||||
const effectiveServerUrl = selectedServer?.url
|
||||
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
|
||||
// Capture before login() so the isAuthenticated effect can build the
|
||||
// deep-link fragment with values the user actually typed (formData may
|
||||
// be cleared by the auth store on success).
|
||||
if (isMobileHandoff) {
|
||||
mobileHandoffPayloadRef.current = {
|
||||
server_url: effectiveServerUrl,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
};
|
||||
}
|
||||
const success = await login(
|
||||
effectiveServerUrl,
|
||||
formData.username,
|
||||
@@ -446,7 +621,15 @@ export default function LoginPage() {
|
||||
|
||||
if (success) {
|
||||
saveUsername(formData.username);
|
||||
if (isMobileHandoff) {
|
||||
// The isAuthenticated effect handles the redirect; nothing else to
|
||||
// do here. Don't push to / - that would race the deep link.
|
||||
return;
|
||||
}
|
||||
router.push('/');
|
||||
} else if (isMobileHandoff) {
|
||||
// Stale payload should never feed into a later retry's redirect.
|
||||
mobileHandoffPayloadRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -556,14 +739,18 @@ export default function LoginPage() {
|
||||
<div className="px-8 pb-10 pt-4">
|
||||
{error && (
|
||||
<div className={cn(
|
||||
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
|
||||
"mb-5 p-3 rounded-xl border border-destructive/20 bg-destructive/5 flex items-start gap-3",
|
||||
shakeError && "animate-shake"
|
||||
)}>
|
||||
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
|
||||
<div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 self-center">
|
||||
<p className="text-sm text-destructive leading-relaxed">
|
||||
{t(`error.${error}`) || t("error.generic")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
@@ -703,38 +890,46 @@ export default function LoginPage() {
|
||||
{/* Session Expired Banner */}
|
||||
{sessionExpired && (
|
||||
<div
|
||||
className="mb-5 p-3.5 bg-blue-500/10 border border-blue-500/20 rounded-xl flex items-start gap-3"
|
||||
className="mb-5 p-3 rounded-xl border border-info/20 bg-info/5 flex items-start gap-3"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Info className="w-4.5 h-4.5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 flex-1 leading-relaxed">
|
||||
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<Info className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 self-center flex items-center gap-2">
|
||||
<p className="text-sm text-info flex-1 leading-relaxed">
|
||||
{t("session_expired")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSessionExpired(false)}
|
||||
className="p-0.5 rounded-md hover:bg-blue-500/10 transition-colors flex-shrink-0"
|
||||
className="p-1 rounded-md text-info hover:bg-info/10 transition-colors flex-shrink-0"
|
||||
aria-label={t("dismiss")}
|
||||
>
|
||||
<X className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className={cn(
|
||||
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
|
||||
"mb-5 p-3 rounded-xl border border-destructive/20 bg-destructive/5 flex items-start gap-3",
|
||||
shakeError && "animate-shake"
|
||||
)}>
|
||||
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
|
||||
<div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 self-center">
|
||||
<p className="text-sm text-destructive leading-relaxed">
|
||||
{error === 'invalid_credentials' && showTotpField && totpCode
|
||||
? t('error.totp_invalid')
|
||||
: t(`error.${error}`) || t("error.generic")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dev Mode: One-click login */}
|
||||
@@ -785,12 +980,16 @@ export default function LoginPage() {
|
||||
)}
|
||||
</Button>
|
||||
) : oauthDiscoveryDone ? (
|
||||
<div className="p-3.5 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-warning">
|
||||
<div className="p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 self-center">
|
||||
<p className="text-sm text-warning leading-relaxed">
|
||||
{t("error.oauth_discovery_failed")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
@@ -801,8 +1000,32 @@ export default function LoginPage() {
|
||||
/* Login Form */
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<fieldset disabled={isLoading} className="space-y-4">
|
||||
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
|
||||
{allowCustomJmapEndpoint && (
|
||||
{/* Server picker (when admin has configured a server list) */}
|
||||
{hasServerList && jmapServers.length > 1 && (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="jmap-server-select" className="block text-sm font-medium text-foreground">
|
||||
{t("jmap_server_label")}
|
||||
</label>
|
||||
<select
|
||||
id="jmap-server-select"
|
||||
value={selectedServer?.id ?? ""}
|
||||
onChange={(e) => setSelectedServerId(e.target.value)}
|
||||
disabled={domainAutoLocked}
|
||||
className="h-11 w-full px-3.5 bg-muted/40 border border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-sm text-foreground disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{jmapServers.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{domainAutoLocked && (
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
{t("jmap_server_auto_picked")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* JMAP Endpoint field (only when no server list and custom endpoints are allowed) */}
|
||||
{!hasServerList && allowCustomJmapEndpoint && (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
|
||||
{t("jmap_endpoint_label")}
|
||||
@@ -1017,12 +1240,16 @@ export default function LoginPage() {
|
||||
)}
|
||||
|
||||
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
|
||||
<div className="mt-2 p-3 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-warning">
|
||||
<div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 self-center">
|
||||
<p className="text-sm text-warning leading-relaxed">
|
||||
{t("error.oauth_discovery_failed")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,402 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type ComponentType, type DragEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { EmbeddedContext } from "@/hooks/use-is-embedded";
|
||||
import { PaneSizeContext } from "@/hooks/use-pane-size";
|
||||
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
|
||||
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import MailPage from "@/app/(main)/[locale]/page";
|
||||
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
|
||||
import ContactsPage from "@/app/(main)/[locale]/contacts/page";
|
||||
import FilesPage from "@/app/(main)/[locale]/files/page";
|
||||
import SettingsPage from "@/app/(main)/[locale]/settings/page";
|
||||
import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
|
||||
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
|
||||
|
||||
const APP_TAB_COMPONENTS: Partial<Record<ProTabKind, ComponentType>> = {
|
||||
mail: MailPage,
|
||||
calendar: CalendarPage,
|
||||
contacts: ContactsPage,
|
||||
files: FilesPage,
|
||||
settings: SettingsPage,
|
||||
};
|
||||
|
||||
type DropTarget = 'left' | 'right' | null;
|
||||
|
||||
function renderTabBody(tab: ProTab): React.ReactNode {
|
||||
if (tab.kind === 'compose' && tab.composeData) {
|
||||
return <ProComposeTabBody tabId={tab.id} data={tab.composeData} />;
|
||||
}
|
||||
if (tab.kind === 'email' && tab.emailData) {
|
||||
return <ProEmailTabBody tabId={tab.id} data={tab.emailData} />;
|
||||
}
|
||||
const Component = APP_TAB_COMPONENTS[tab.kind];
|
||||
return Component ? <Component /> : null;
|
||||
}
|
||||
|
||||
interface PaneProps {
|
||||
paneId: ProPaneId;
|
||||
tabs: ProTab[];
|
||||
activeTabId: string | null;
|
||||
loadedTabIds: string[];
|
||||
onPaneFocus: (paneId: ProPaneId) => void;
|
||||
isFocused: boolean;
|
||||
}
|
||||
|
||||
function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) {
|
||||
const paneRef = useRef<HTMLDivElement | null>(null);
|
||||
// Measured pane width, published to children via PaneSizeContext so that
|
||||
// useDeviceDetection / useIsMobile / etc. branch on pane width - not full
|
||||
// viewport - and inner pages collapse to their mobile/tablet layouts when
|
||||
// the pane is narrow.
|
||||
const [paneWidth, setPaneWidth] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = paneRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const initialRect = el.getBoundingClientRect();
|
||||
if (initialRect.width > 0) setPaneWidth(initialRect.width);
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
const w = entry.contentRect.width;
|
||||
setPaneWidth((prev) => (prev !== null && Math.abs(prev - w) < 0.5 ? prev : w));
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={paneRef}
|
||||
className="relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0"
|
||||
onMouseDownCapture={() => { if (!isFocused) onPaneFocus(paneId); }}
|
||||
>
|
||||
<PaneSizeContext.Provider value={paneWidth}>
|
||||
{tabs
|
||||
.filter((tab) => loadedTabIds.includes(tab.id))
|
||||
.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
{renderTabBody(tab)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</PaneSizeContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProHome() {
|
||||
const t = useTranslations();
|
||||
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
|
||||
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(
|
||||
() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client
|
||||
);
|
||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||
const {
|
||||
showAppsModal,
|
||||
inlineApp,
|
||||
loadedApps,
|
||||
handleManageApps,
|
||||
handleInlineApp,
|
||||
closeInlineApp,
|
||||
closeAppsModal,
|
||||
} = useSidebarApps();
|
||||
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const checkAuth = useAuthStore((s) => s.checkAuth);
|
||||
const authLoading = useAuthStore((s) => s.isLoading);
|
||||
const quota = useEmailStore((s) => s.quota);
|
||||
const isPushConnected = useEmailStore((s) => s.isPushConnected);
|
||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||
|
||||
const tabs = useProTabStore((s) => s.tabs);
|
||||
const activeMainTabId = useProTabStore((s) => s.activeTabId);
|
||||
const activeSplitTabId = useProTabStore((s) => s.activeSplitTabId);
|
||||
const splitOrientation = useProTabStore((s) => s.splitOrientation);
|
||||
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
||||
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
||||
const openTab = useProTabStore((s) => s.openTab);
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const setActiveTab = useProTabStore((s) => s.setActiveTab);
|
||||
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
|
||||
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
|
||||
|
||||
const [isTabDragging, setIsTabDragging] = useState(false);
|
||||
const [splitDropTarget, setSplitDropTarget] = useState<DropTarget>(null);
|
||||
/** Whether the split pane visually renders before (true) or after (false) main. */
|
||||
const [splitLeading, setSplitLeading] = useState(false);
|
||||
|
||||
// Auth bootstrap (mirrors standard page)
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialCheckDone || typeof window === "undefined") return;
|
||||
// Pro is desktop-only, and only used when the user has explicitly
|
||||
// enabled it. If either precondition stops holding, hand the user back
|
||||
// to the standard shell.
|
||||
if (isMobile || isTablet || !proInterface) {
|
||||
window.location.replace("/");
|
||||
}
|
||||
}, [initialCheckDone, isMobile, isTablet, proInterface]);
|
||||
|
||||
const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]);
|
||||
const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]);
|
||||
|
||||
const focusedActiveTab = useMemo(() => {
|
||||
const id = focusedPaneId === 'main' ? activeMainTabId : activeSplitTabId;
|
||||
return tabs.find((t) => t.id === id) ?? null;
|
||||
}, [tabs, focusedPaneId, activeMainTabId, activeSplitTabId]);
|
||||
|
||||
const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => {
|
||||
openTab(itemId);
|
||||
return true;
|
||||
};
|
||||
|
||||
const railActiveItemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null =
|
||||
focusedActiveTab && (
|
||||
focusedActiveTab.kind === 'mail' || focusedActiveTab.kind === 'calendar'
|
||||
|| focusedActiveTab.kind === 'contacts' || focusedActiveTab.kind === 'files'
|
||||
|| focusedActiveTab.kind === 'settings'
|
||||
) ? focusedActiveTab.kind : null;
|
||||
|
||||
const isSplit = splitOrientation !== null && splitTabs.length > 0;
|
||||
|
||||
// ---- Body-level drop targets ----
|
||||
|
||||
const isProTabDrag = (e: DragEvent) => e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
|
||||
|
||||
const computeDropTarget = (e: DragEvent<HTMLDivElement>): DropTarget => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const xFrac = (e.clientX - rect.left) / rect.width;
|
||||
return xFrac < 0.5 ? 'left' : 'right';
|
||||
};
|
||||
|
||||
const targetPaneFromDrop = (target: DropTarget): ProPaneId | null => {
|
||||
if (!target || !isSplit) return null;
|
||||
const leftIsSplit = splitLeading;
|
||||
if (target === 'left') return leftIsSplit ? 'split' : 'main';
|
||||
return leftIsSplit ? 'main' : 'split';
|
||||
};
|
||||
|
||||
const handleBodyDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isProTabDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const next = computeDropTarget(e);
|
||||
if (next !== splitDropTarget) setSplitDropTarget(next);
|
||||
};
|
||||
|
||||
const handleBodyDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (next && e.currentTarget.contains(next)) return;
|
||||
setSplitDropTarget(null);
|
||||
};
|
||||
|
||||
const handleBodyDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isProTabDrag(e)) return;
|
||||
const target = computeDropTarget(e);
|
||||
setSplitDropTarget(null);
|
||||
setIsTabDragging(false);
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
|
||||
if (!draggedId) return;
|
||||
|
||||
if (isSplit) {
|
||||
// Move tab to whichever pane occupies the dropped side.
|
||||
const destPane = targetPaneFromDrop(target);
|
||||
if (destPane) moveTabToPane(draggedId, destPane);
|
||||
return;
|
||||
}
|
||||
// Create a new side-by-side split. `splitLeading` controls which side
|
||||
// visually hosts the split pane.
|
||||
moveTabToPane(draggedId, 'split', 'vertical');
|
||||
setSplitLeading(target === 'left');
|
||||
};
|
||||
|
||||
// Loading state (matches standard page exactly)
|
||||
if (!initialCheckDone || authLoading || !isAuthenticated || !client) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDesktop) return null;
|
||||
|
||||
// Stable keys are essential: when the split collapses, the row's child
|
||||
// list goes from [splitPane, divider, mainPane] (or the leading variant)
|
||||
// to [mainPane]. Without keys, React would reuse the Pane instance at
|
||||
// index 0 - repurposing the *split* pane's instance into the main pane,
|
||||
// which strands the main pane's ResizeObserver/paneWidth on a now-
|
||||
// unmounted DOM node and reparents the mail tab body (causing remount
|
||||
// + stale "still-narrow" measurements after the split is closed).
|
||||
const mainPane = (
|
||||
<Pane
|
||||
key="pane-main"
|
||||
paneId="main"
|
||||
tabs={mainTabs}
|
||||
activeTabId={activeMainTabId}
|
||||
loadedTabIds={loadedTabIds}
|
||||
onPaneFocus={setFocusedPane}
|
||||
isFocused={focusedPaneId === 'main'}
|
||||
/>
|
||||
);
|
||||
|
||||
const splitPane = isSplit ? (
|
||||
<Pane
|
||||
key="pane-split"
|
||||
paneId="split"
|
||||
tabs={splitTabs}
|
||||
activeTabId={activeSplitTabId}
|
||||
loadedTabIds={loadedTabIds}
|
||||
onPaneFocus={setFocusedPane}
|
||||
isFocused={focusedPaneId === 'split'}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const splitDivider = isSplit ? (
|
||||
<div
|
||||
key="pane-divider"
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0 w-px bg-transparent"
|
||||
style={{ borderLeft: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
// Drop-zone overlay: a single half-body preview of where the dragged tab
|
||||
// would land. The whole body is always a drop target (the entire surface
|
||||
// maps to one of the four sides), so we only render the active side.
|
||||
const dropZone = isTabDragging && splitDropTarget ? (
|
||||
<DropZone side={splitDropTarget} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<EmbeddedContext.Provider value={true}>
|
||||
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Leftmost Navigation Rail - identical to the standard layout */}
|
||||
<div
|
||||
className="w-14 bg-secondary flex flex-col flex-shrink-0"
|
||||
style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||
>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={logout}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
onNavigate={handleRailNavigate}
|
||||
activeItemId={railActiveItemId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inlineApp && (
|
||||
<InlineAppView
|
||||
apps={loadedApps}
|
||||
activeAppId={inlineApp.id}
|
||||
onClose={closeInlineApp}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!inlineApp && (
|
||||
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
|
||||
{/* Single, unified tab bar above both panes. */}
|
||||
<ProTabBar
|
||||
tabs={tabs}
|
||||
activeMainTabId={activeMainTabId}
|
||||
activeSplitTabId={activeSplitTabId}
|
||||
onActivate={setActiveTab}
|
||||
onClose={closeTab}
|
||||
onDragStateChange={setIsTabDragging}
|
||||
/>
|
||||
|
||||
{/* Panes container - accepts body drops for split/move. */}
|
||||
<div
|
||||
className="relative flex flex-row flex-1 overflow-hidden min-w-0"
|
||||
onDragOver={handleBodyDragOver}
|
||||
onDragLeave={handleBodyDragLeave}
|
||||
onDrop={handleBodyDrop}
|
||||
>
|
||||
{isSplit
|
||||
? (splitLeading
|
||||
? <>{splitPane}{splitDivider}{mainPane}</>
|
||||
: <>{mainPane}{splitDivider}{splitPane}</>)
|
||||
: mainPane}
|
||||
|
||||
{dropZone}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<KeyboardShortcutsModal
|
||||
isOpen={showShortcutsModal}
|
||||
onClose={() => setShowShortcutsModal(false)}
|
||||
/>
|
||||
{showAppsModal && (
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
)}
|
||||
</div>
|
||||
</EmbeddedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function DropZone({ side }: { side: 'left' | 'right' }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-0 bottom-0 w-1/2 z-10",
|
||||
"bg-primary/15 ring-2 ring-primary/40 ring-inset",
|
||||
side === 'left' ? "left-0" : "right-0",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { useTranslations, useMessages } from 'next-intl';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
Settings as SettingsIcon,
|
||||
Palette,
|
||||
Search,
|
||||
User,
|
||||
Shield,
|
||||
UserPen,
|
||||
PalmtreeIcon,
|
||||
Calendar,
|
||||
Filter,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Tags,
|
||||
HardDrive,
|
||||
BookUser,
|
||||
KeyRound,
|
||||
PanelLeftClose,
|
||||
Bell,
|
||||
Puzzle,
|
||||
LayoutGrid,
|
||||
Link as LinkIcon,
|
||||
BookOpen,
|
||||
PenLine,
|
||||
EyeOff,
|
||||
Languages,
|
||||
Info,
|
||||
Bug,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||
import { AppTopBannerSlot } from '@/components/plugins/app-top-banner-slot';
|
||||
import { LayoutSettings } from '@/components/settings/layout-settings';
|
||||
import { LanguageSettings } from '@/components/settings/language-settings';
|
||||
import { ReadingSettings } from '@/components/settings/reading-settings';
|
||||
import { ComposingSettings } from '@/components/settings/composing-settings';
|
||||
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
|
||||
import { AccountSettings } from '@/components/settings/account-settings';
|
||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||
import { CalendarSettings } from '@/components/settings/calendar-settings';
|
||||
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
|
||||
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
|
||||
import { FilterSettings } from '@/components/settings/filter-settings';
|
||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||
import { AboutDataSettings } from '@/components/settings/about-data-settings';
|
||||
import { DebugSettings } from '@/components/settings/debug-settings';
|
||||
import { FolderSettings } from '@/components/settings/folder-settings';
|
||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||
import { InlineAppView } from '@/components/layout/inline-app-view';
|
||||
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
||||
import { useIsEmbedded } from '@/hooks/use-is-embedded';
|
||||
import { ResizeHandle } from '@/components/layout/resize-handle';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Tab =
|
||||
| 'account'
|
||||
| 'language'
|
||||
| 'notifications'
|
||||
| 'appearance'
|
||||
| 'layout'
|
||||
| 'reading'
|
||||
| 'composing'
|
||||
| 'identities'
|
||||
| 'vacation'
|
||||
| 'filters'
|
||||
| 'templates'
|
||||
| 'folders'
|
||||
| 'keywords'
|
||||
| 'security'
|
||||
| 'encryption'
|
||||
| 'content_senders'
|
||||
| 'calendar'
|
||||
| 'contacts'
|
||||
| 'files'
|
||||
| 'protocol_handlers'
|
||||
| 'sidebar_apps'
|
||||
| 'about_data'
|
||||
| 'themes'
|
||||
| 'plugins'
|
||||
| 'debug';
|
||||
|
||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||
|
||||
interface TabDef {
|
||||
id: Tab;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
group: TabGroup;
|
||||
}
|
||||
|
||||
const tabIcons: Record<Tab, LucideIcon> = {
|
||||
account: User,
|
||||
language: Languages,
|
||||
notifications: Bell,
|
||||
appearance: Palette,
|
||||
layout: LayoutGrid,
|
||||
reading: BookOpen,
|
||||
composing: PenLine,
|
||||
identities: UserPen,
|
||||
vacation: PalmtreeIcon,
|
||||
filters: Filter,
|
||||
templates: FileText,
|
||||
folders: FolderOpen,
|
||||
keywords: Tags,
|
||||
security: Shield,
|
||||
encryption: KeyRound,
|
||||
content_senders: EyeOff,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
files: HardDrive,
|
||||
protocol_handlers: LinkIcon,
|
||||
sidebar_apps: PanelLeftClose,
|
||||
about_data: Info,
|
||||
themes: Palette,
|
||||
plugins: Puzzle,
|
||||
debug: Bug,
|
||||
};
|
||||
|
||||
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
|
||||
|
||||
// Translation paths per tab. Tabs that share a namespace (email_behavior,
|
||||
// appearance) explicitly list the subkeys they actually render so sub-results
|
||||
// are attributed to the correct tab. Tabs with their own namespace just point
|
||||
// at the namespace root.
|
||||
const tabSearchPaths: Record<Tab, string[]> = {
|
||||
account: [
|
||||
'settings.account.name_label',
|
||||
'settings.account.username_label',
|
||||
'settings.account.account_type_label',
|
||||
'settings.account.auth_method_label',
|
||||
'settings.account.email',
|
||||
'settings.account.server',
|
||||
'settings.account.storage',
|
||||
'settings.account.accounts',
|
||||
],
|
||||
language: ['settings.appearance.language'],
|
||||
notifications: ['settings.notifications'],
|
||||
appearance: [
|
||||
'settings.appearance.theme',
|
||||
'settings.appearance.font_size',
|
||||
'settings.appearance.list_density',
|
||||
'settings.appearance.animations',
|
||||
],
|
||||
layout: [
|
||||
'settings.appearance.toolbar_position',
|
||||
'settings.appearance.toolbar_labels',
|
||||
'settings.appearance.hide_account_switcher',
|
||||
'settings.appearance.show_rail_account_list',
|
||||
'settings.appearance.unified_mailbox',
|
||||
'settings.appearance.colorful_sidebar_icons',
|
||||
'settings.email_behavior.mail_layout',
|
||||
],
|
||||
reading: [
|
||||
'settings.email_behavior.mark_read',
|
||||
'settings.email_behavior.archive_mode',
|
||||
'settings.email_behavior.delete_action',
|
||||
'settings.email_behavior.attachment_click_action',
|
||||
'settings.email_behavior.attachment_image_previews',
|
||||
'settings.email_behavior.attachment_position',
|
||||
'settings.email_behavior.disable_threading',
|
||||
'settings.email_behavior.emails_per_page',
|
||||
'settings.email_behavior.hide_inline_image_attachments',
|
||||
'settings.email_behavior.hover_actions',
|
||||
'settings.email_behavior.permanently_delete_junk',
|
||||
'settings.email_behavior.show_preview',
|
||||
'settings.email_behavior.plain_text_mode',
|
||||
],
|
||||
composing: [
|
||||
'settings.email_behavior.attachment_reminder',
|
||||
'settings.email_behavior.auto_select_reply_identity',
|
||||
'settings.email_behavior.default_mail_program',
|
||||
'settings.email_behavior.signature_position',
|
||||
'settings.email_behavior.sub_address_delimiter',
|
||||
],
|
||||
identities: ['settings.identities'],
|
||||
vacation: ['settings.vacation'],
|
||||
filters: ['settings.filters'],
|
||||
templates: ['settings.templates'],
|
||||
folders: ['settings.folders'],
|
||||
keywords: ['settings.keywords'],
|
||||
security: ['settings.security'],
|
||||
encryption: ['smime'],
|
||||
content_senders: [
|
||||
'settings.email_behavior.always_light_mode',
|
||||
'settings.email_behavior.external_content',
|
||||
'settings.email_behavior.trusted_senders',
|
||||
],
|
||||
calendar: ['calendar.settings', 'calendar.management'],
|
||||
contacts: ['settings.contacts', 'contacts'],
|
||||
files: ['settings.files'],
|
||||
protocol_handlers: ['protocol_handlers'],
|
||||
sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'],
|
||||
about_data: ['settings.advanced'],
|
||||
themes: [],
|
||||
plugins: [],
|
||||
debug: ['settings.advanced'],
|
||||
};
|
||||
|
||||
// Extra English keywords per tab so common search terms hit even when the
|
||||
// translation doesn't contain the literal word.
|
||||
const tabKeywords: Record<Tab, string> = {
|
||||
account: 'profile email password user signin signout reorder rearrange drag dropdown switcher multi-account',
|
||||
language: 'locale region timezone date time format',
|
||||
notifications: 'sound alert push badge',
|
||||
appearance: 'theme dark light font size accent color animation density',
|
||||
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
|
||||
reading: 'mark read preview thread conversation archive delete attachment open',
|
||||
composing: 'editor signature plain text reply forward draft compose',
|
||||
identities: 'from address signature email',
|
||||
vacation: 'auto reply away out of office holiday responder',
|
||||
filters: 'sieve rules block junk forward',
|
||||
templates: 'snippet quick reply',
|
||||
folders: 'mailbox subscribe',
|
||||
keywords: 'tags labels colors',
|
||||
security: 'password 2fa two-factor passkey app password mfa',
|
||||
encryption: 's/mime smime certificate pgp gpg',
|
||||
content_senders: 'block sender remote images privacy tracking',
|
||||
calendar: 'event schedule appointment meeting timezone',
|
||||
contacts: 'address book contact',
|
||||
files: 'attachments cloud drive storage upload',
|
||||
protocol_handlers: 'mailto webcal links default app protocol handler',
|
||||
sidebar_apps: 'apps webview iframe',
|
||||
about_data: 'export import storage quota privacy backup',
|
||||
themes: 'custom theme css skin appearance',
|
||||
plugins: 'extensions addons',
|
||||
debug: 'logs developer console diagnostic',
|
||||
};
|
||||
|
||||
function flattenStrings(node: unknown, sink: string[]): void {
|
||||
if (typeof node === 'string') {
|
||||
sink.push(node);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) flattenStrings(item, sink);
|
||||
return;
|
||||
}
|
||||
if (node && typeof node === 'object') {
|
||||
for (const value of Object.values(node)) flattenStrings(value, sink);
|
||||
}
|
||||
}
|
||||
|
||||
interface SubResult {
|
||||
label: string;
|
||||
description?: string;
|
||||
// For plugin setting fields: the id of the plugin whose card needs to be
|
||||
// expanded before the field becomes visible in the DOM.
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
// Walk a translation subtree and emit sub-results for renderable settings.
|
||||
// Picks up:
|
||||
// - bare string leaves (when a tab path points directly at a flat label)
|
||||
// - objects with a `label` or `title` field (the standard pattern)
|
||||
// - flat `*_label` string keys at any object level (e.g. `name_label`)
|
||||
function collectSubResults(node: unknown, sink: SubResult[]): void {
|
||||
if (typeof node === 'string') {
|
||||
sink.push({ label: node });
|
||||
return;
|
||||
}
|
||||
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
|
||||
const obj = node as Record<string, unknown>;
|
||||
const label = typeof obj.label === 'string' ? obj.label : (typeof obj.title === 'string' ? obj.title : undefined);
|
||||
if (label) {
|
||||
sink.push({
|
||||
label,
|
||||
description: typeof obj.description === 'string' ? obj.description : undefined,
|
||||
});
|
||||
}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === 'string' && key !== 'label' && key !== 'title' && key.endsWith('_label')) {
|
||||
sink.push({ label: value });
|
||||
}
|
||||
}
|
||||
for (const value of Object.values(obj)) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
collectSubResults(value, sink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getByPath(obj: unknown, path: string): unknown {
|
||||
let cur: unknown = obj;
|
||||
for (const key of path.split('.')) {
|
||||
if (cur && typeof cur === 'object' && key in (cur as Record<string, unknown>)) {
|
||||
cur = (cur as Record<string, unknown>)[key];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
// Map legacy tab IDs to current ones; runs once on read of localStorage.
|
||||
const LEGACY_TAB_MAP: Record<string, Tab> = {
|
||||
email: 'reading',
|
||||
advanced: 'about_data',
|
||||
};
|
||||
|
||||
function readPersistedTab(): Tab {
|
||||
try {
|
||||
const saved = localStorage.getItem('settings-active-tab');
|
||||
if (!saved) return 'appearance';
|
||||
if (saved in LEGACY_TAB_MAP) {
|
||||
const migrated = LEGACY_TAB_MAP[saved];
|
||||
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
||||
return migrated;
|
||||
}
|
||||
return saved as Tab;
|
||||
} catch {
|
||||
return 'appearance';
|
||||
}
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations('settings');
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const { stalwartFeaturesEnabled } = useConfig();
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
const messages = useMessages() as Record<string, unknown>;
|
||||
const installedPlugins = usePluginStore((s) => s.plugins);
|
||||
const installedThemes = useThemeStore((s) => s.installedThemes);
|
||||
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||
|
||||
// Build a per-tab haystack for fulltext search and a list of sub-results
|
||||
// (individual settings) per tab. Sub-results come from translation entries
|
||||
// that have a `label`/`title` field, plus dynamic content (installed
|
||||
// plugins/themes/sidebar apps).
|
||||
const { tabSearchHaystacks, tabSubResults } = useMemo(() => {
|
||||
const haystacks: Partial<Record<Tab, string>> = {};
|
||||
const subs: Partial<Record<Tab, SubResult[]>> = {};
|
||||
const tabIds = Object.keys(tabSearchPaths) as Tab[];
|
||||
for (const tabId of tabIds) {
|
||||
const strings: string[] = [tabId.replace(/_/g, ' '), tabKeywords[tabId] ?? ''];
|
||||
const list: SubResult[] = [];
|
||||
for (const path of tabSearchPaths[tabId]) {
|
||||
const node = getByPath(messages, path);
|
||||
flattenStrings(node, strings);
|
||||
collectSubResults(node, list);
|
||||
}
|
||||
// Dedupe sub-results by label
|
||||
const seen = new Set<string>();
|
||||
subs[tabId] = list.filter((r) => {
|
||||
if (seen.has(r.label)) return false;
|
||||
seen.add(r.label);
|
||||
return true;
|
||||
});
|
||||
haystacks[tabId] = strings.join(' ').toLowerCase();
|
||||
}
|
||||
if (installedPlugins.length) {
|
||||
const haystackText = installedPlugins.map((p) => {
|
||||
const fieldText = p.settingsSchema
|
||||
? Object.values(p.settingsSchema)
|
||||
.map((s) => `${s.label} ${s.description ?? ''}`)
|
||||
.join(' ')
|
||||
: '';
|
||||
return `${p.name} ${p.description} ${p.author} ${fieldText}`;
|
||||
}).join(' ');
|
||||
haystacks.plugins = `${haystacks.plugins ?? ''} ${haystackText}`.toLowerCase();
|
||||
const pluginSubs: SubResult[] = installedPlugins.flatMap((p) => {
|
||||
const items: SubResult[] = [{ label: p.name, description: p.description }];
|
||||
if (p.settingsSchema) {
|
||||
for (const schema of Object.values(p.settingsSchema)) {
|
||||
items.push({
|
||||
label: schema.label,
|
||||
description: schema.description,
|
||||
pluginId: p.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
});
|
||||
subs.plugins = [...(subs.plugins ?? []), ...pluginSubs];
|
||||
}
|
||||
if (installedThemes.length) {
|
||||
const text = installedThemes.map((th) => `${th.name} ${th.description} ${th.author}`).join(' ');
|
||||
haystacks.themes = `${haystacks.themes ?? ''} ${text}`.toLowerCase();
|
||||
subs.themes = [
|
||||
...(subs.themes ?? []),
|
||||
...installedThemes.map((th) => ({ label: th.name, description: th.description })),
|
||||
];
|
||||
}
|
||||
if (sidebarAppsList.length) {
|
||||
const text = sidebarAppsList.map((a) => `${a.name} ${a.url}`).join(' ');
|
||||
haystacks.sidebar_apps = `${haystacks.sidebar_apps ?? ''} ${text}`.toLowerCase();
|
||||
subs.sidebar_apps = [
|
||||
...(subs.sidebar_apps ?? []),
|
||||
...sidebarAppsList.map((a) => ({ label: a.name, description: a.url })),
|
||||
];
|
||||
}
|
||||
return { tabSearchHaystacks: haystacks, tabSubResults: subs };
|
||||
}, [messages, installedPlugins, installedThemes, sidebarAppsList]);
|
||||
|
||||
// Sidebar resize state
|
||||
const [settingsSidebarWidth, setSettingsSidebarWidth] = useState(() => {
|
||||
try { const v = localStorage.getItem('settings-sidebar-width'); return v ? Number(v) : 256; } catch { return 256; }
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const dragStartWidth = useRef(256);
|
||||
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
}, [checkAuth]);
|
||||
|
||||
// Listen for tab change events from child components (with legacy migration)
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const raw = (e as CustomEvent).detail as string;
|
||||
if (!raw) return;
|
||||
const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab;
|
||||
setActiveTab(tab);
|
||||
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
|
||||
};
|
||||
window.addEventListener('settings-tab-change', handler);
|
||||
return () => window.removeEventListener('settings-tab-change', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
// Sync the mobile submenu view with browser history so the system back
|
||||
// button (or gesture) returns to the settings list before exiting /settings.
|
||||
useEffect(() => {
|
||||
if (isDesktop) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!mobileShowContent) return;
|
||||
|
||||
window.history.pushState({ __settingsSubmenu: true }, '');
|
||||
|
||||
const handlePop = () => {
|
||||
setMobileShowContent(false);
|
||||
};
|
||||
window.addEventListener('popstate', handlePop);
|
||||
return () => window.removeEventListener('popstate', handlePop);
|
||||
}, [isDesktop, mobileShowContent]);
|
||||
|
||||
// After clicking a search sub-result, scroll the matching setting into view
|
||||
// and add a temporary highlight class. Some tabs fetch data and render
|
||||
// their SettingItems only after a loading state, so retry until the element
|
||||
// shows up (or we give up after ~2s).
|
||||
useEffect(() => {
|
||||
if (!pendingHighlight) return;
|
||||
if (pendingHighlight.tab !== activeTab) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// For plugin-setting sub-results, ask the plugins tab to expand the
|
||||
// matching card so the field becomes part of the DOM. Dispatched here
|
||||
// (not in the click handler) because PluginsSettings only mounts after
|
||||
// the tab switches, and its listener registers in its own useEffect -
|
||||
// child effects run before parent effects, so by the time we get here
|
||||
// the listener is guaranteed to be in place.
|
||||
if (pendingHighlight.pluginId) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('settings-plugin-expand', { detail: { pluginId: pendingHighlight.pluginId } })
|
||||
);
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let cleanupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let highlightedEl: HTMLElement | null = null;
|
||||
|
||||
const escaped = pendingHighlight.label.replace(/"/g, '\\"');
|
||||
const selector = `[data-search-label="${escaped}"]`;
|
||||
const deadline = Date.now() + 2000;
|
||||
|
||||
const tryHighlight = () => {
|
||||
if (cancelled) return;
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
if (!el) {
|
||||
if (Date.now() < deadline) {
|
||||
retryTimer = setTimeout(tryHighlight, 80);
|
||||
}
|
||||
return;
|
||||
}
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Remove + reflow + add restarts the CSS animation if the class was
|
||||
// already present (re-clicking the same sub-result).
|
||||
el.classList.remove('settings-search-highlight');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('settings-search-highlight');
|
||||
highlightedEl = el;
|
||||
cleanupTimer = setTimeout(() => {
|
||||
el.classList.remove('settings-search-highlight');
|
||||
highlightedEl = null;
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
// First attempt next frame so the freshly-mounted tab content is in DOM.
|
||||
const raf = window.requestAnimationFrame(tryHighlight);
|
||||
|
||||
// Do NOT reset pendingHighlight here - that would retrigger this effect
|
||||
// and the cleanup below would strip the class right after we added it.
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.cancelAnimationFrame(raf);
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
if (cleanupTimer) clearTimeout(cleanupTimer);
|
||||
if (highlightedEl) highlightedEl.classList.remove('settings-search-highlight');
|
||||
};
|
||||
}, [pendingHighlight, activeTab]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const supportsVacation = client?.supportsVacationResponse() ?? false;
|
||||
const supportsCalendar = client?.supportsCalendars() ?? false;
|
||||
const supportsSieve = client?.supportsSieve() ?? false;
|
||||
const supportsFiles = client?.supportsFiles() ?? false;
|
||||
|
||||
const tabs: TabDef[] = [
|
||||
// General
|
||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
||||
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
|
||||
|
||||
// Appearance
|
||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
|
||||
|
||||
// Mail
|
||||
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
|
||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
|
||||
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
|
||||
|
||||
// Privacy & Security
|
||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
|
||||
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
||||
|
||||
// Apps
|
||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
||||
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||
|
||||
// Advanced
|
||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||
];
|
||||
|
||||
// Group tabs by category
|
||||
const groupedTabs = tabGroupOrder
|
||||
.map((group) => ({
|
||||
group,
|
||||
label: t(`tab_groups.${group}`),
|
||||
items: tabs.filter((tab) => tab.group === group),
|
||||
}))
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
const trimmedQuery = searchQuery.trim().toLowerCase();
|
||||
const matchesQuery = (tab: TabDef) => {
|
||||
if (!trimmedQuery) return true;
|
||||
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
|
||||
return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false;
|
||||
};
|
||||
|
||||
const subResultsForTab = (tabId: Tab): SubResult[] => {
|
||||
if (!trimmedQuery) return [];
|
||||
const list = tabSubResults[tabId] ?? [];
|
||||
return list
|
||||
.filter((r) =>
|
||||
r.label.toLowerCase().includes(trimmedQuery) ||
|
||||
(r.description?.toLowerCase().includes(trimmedQuery) ?? false)
|
||||
)
|
||||
.slice(0, 6);
|
||||
};
|
||||
|
||||
const filteredGroupedTabs = trimmedQuery
|
||||
? groupedTabs
|
||||
.map((g) => ({ ...g, items: g.items.filter(matchesQuery) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
: groupedTabs;
|
||||
|
||||
// If active tab is not in the visible list (e.g., feature disabled), fall back.
|
||||
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
|
||||
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
|
||||
|
||||
const handleTabSelect = (tabId: Tab) => {
|
||||
setActiveTab(tabId);
|
||||
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
||||
if (!isDesktop) {
|
||||
setMobileShowContent(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubResultSelect = (tabId: Tab, sub: SubResult) => {
|
||||
handleTabSelect(tabId);
|
||||
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
||||
};
|
||||
|
||||
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||
|
||||
const renderTabContent = () => (
|
||||
<>
|
||||
{effectiveActiveTab === 'account' && <AccountSettings />}
|
||||
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
||||
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
||||
{effectiveActiveTab === 'appearance' && <AppearanceSettings />}
|
||||
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
||||
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||
</>
|
||||
);
|
||||
|
||||
// Mobile layout
|
||||
if (!isDesktop) {
|
||||
if (mobileShowContent) {
|
||||
return (
|
||||
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => window.history.back()}
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
|
||||
{!isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push('/')}
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsIcon className="w-5 h-5 text-muted-foreground" />
|
||||
<h1 className="font-semibold text-lg">{t('title')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('search_placeholder')}
|
||||
className="pl-9 pr-9 h-10"
|
||||
aria-label={t('search_placeholder')}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:bg-muted"
|
||||
aria-label={t('search_clear')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
{filteredGroupedTabs.length === 0 && (
|
||||
<div className="px-5 py-6 text-sm text-muted-foreground text-center">
|
||||
{t('search_no_results')}
|
||||
</div>
|
||||
)}
|
||||
{filteredGroupedTabs.map((group, groupIndex) => (
|
||||
<div key={group.group}>
|
||||
{groupIndex > 0 && <div className="mx-5 my-2 border-t border-border" />}
|
||||
<div className="px-5 pt-3 pb-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.items.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const subs = subResultsForTab(tab.id);
|
||||
return (
|
||||
<div key={tab.id}>
|
||||
<button
|
||||
onClick={() => handleTabSelect(tab.id)}
|
||||
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
{tab.label}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
{subs.map((sub) => (
|
||||
<button
|
||||
key={`${tab.id}:${sub.label}`}
|
||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||
className="w-full flex items-center pl-12 pr-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-left"
|
||||
>
|
||||
<span className="truncate">{sub.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>{tSidebar('sign_out')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout
|
||||
return (
|
||||
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{!isEmbedded && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={logout}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||
)}
|
||||
{!inlineApp && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary flex flex-col",
|
||||
!isResizing && "transition-[width] duration-300"
|
||||
)}
|
||||
style={{ width: `${settingsSidebarWidth}px` }}
|
||||
>
|
||||
{!proInterface && (
|
||||
<div className="p-4 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t('back_to_mail')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||
<div className="px-3 pt-1 pb-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('search_placeholder')}
|
||||
className="pl-8 pr-8 h-9 text-sm"
|
||||
aria-label={t('search_placeholder')}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded-md text-muted-foreground hover:bg-muted"
|
||||
aria-label={t('search_clear')}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 space-y-0.5">
|
||||
{filteredGroupedTabs.length === 0 && (
|
||||
<div className="px-3 py-6 text-sm text-muted-foreground text-center">
|
||||
{t('search_no_results')}
|
||||
</div>
|
||||
)}
|
||||
{filteredGroupedTabs.map((group, groupIndex) => (
|
||||
<div key={group.group}>
|
||||
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.items.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const subs = subResultsForTab(tab.id);
|
||||
return (
|
||||
<div key={tab.id}>
|
||||
<button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
effectiveActiveTab === tab.id
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
{tab.label}
|
||||
</button>
|
||||
{subs.map((sub) => (
|
||||
<button
|
||||
key={`${tab.id}:${sub.label}`}
|
||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||
className="w-full text-left pl-9 pr-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
|
||||
>
|
||||
<span className="truncate block">{sub.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsResizing(false);
|
||||
localStorage.setItem('settings-sidebar-width', String(settingsSidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
interface Props {
|
||||
value: JmapServerEntry[];
|
||||
source?: string;
|
||||
onChange: (next: JmapServerEntry[]) => void;
|
||||
onRevert: () => void;
|
||||
}
|
||||
|
||||
interface RowDraft {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
domains: string;
|
||||
oauthClientId: string;
|
||||
oauthIssuerUrl: string;
|
||||
oauthClientSecret: string;
|
||||
oauthExpanded: boolean;
|
||||
}
|
||||
|
||||
function entryToDraft(e: JmapServerEntry): RowDraft {
|
||||
return {
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
url: e.url,
|
||||
domains: (e.domains ?? []).join(', '),
|
||||
oauthClientId: e.oauth?.clientId ?? '',
|
||||
oauthIssuerUrl: e.oauth?.issuerUrl ?? '',
|
||||
oauthClientSecret: e.oauth?.clientSecret ?? '',
|
||||
oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret)),
|
||||
};
|
||||
}
|
||||
|
||||
function draftToEntry(d: RowDraft): JmapServerEntry | null {
|
||||
const id = d.id.trim();
|
||||
const url = d.url.trim().replace(/\/+$/, '');
|
||||
if (!id || !url) return null;
|
||||
const domains = d.domains
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const clientId = d.oauthClientId.trim();
|
||||
const issuerUrl = d.oauthIssuerUrl.trim().replace(/\/+$/, '');
|
||||
const clientSecret = d.oauthClientSecret;
|
||||
const oauth = clientId || issuerUrl || clientSecret
|
||||
? {
|
||||
...(clientId ? { clientId } : {}),
|
||||
...(issuerUrl ? { issuerUrl } : {}),
|
||||
...(clientSecret ? { clientSecret } : {}),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
id,
|
||||
label: d.label.trim() || id,
|
||||
url,
|
||||
...(domains.length > 0 ? { domains } : {}),
|
||||
...(oauth ? { oauth } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyDraft(): RowDraft {
|
||||
return {
|
||||
id: '',
|
||||
label: '',
|
||||
url: '',
|
||||
domains: '',
|
||||
oauthClientId: '',
|
||||
oauthIssuerUrl: '',
|
||||
oauthClientSecret: '',
|
||||
oauthExpanded: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
||||
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
||||
|
||||
useEffect(() => {
|
||||
// Re-sync from props when the underlying config value changes (e.g. revert,
|
||||
// initial load). Skip when drafts already represent the same array to avoid
|
||||
// clobbering in-progress edits.
|
||||
setDrafts((prev) => {
|
||||
if (prev.length === value.length) {
|
||||
const same = prev.every((d, i) => {
|
||||
const e = value[i];
|
||||
return d.id === e.id && d.url === e.url && d.label === e.label;
|
||||
});
|
||||
if (same) return prev;
|
||||
}
|
||||
return value.map(entryToDraft);
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
function commit(next: RowDraft[]) {
|
||||
setDrafts(next);
|
||||
const entries: JmapServerEntry[] = [];
|
||||
for (const d of next) {
|
||||
const e = draftToEntry(d);
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
onChange(entries);
|
||||
}
|
||||
|
||||
function update(idx: number, patch: Partial<RowDraft>) {
|
||||
commit(drafts.map((d, i) => (i === idx ? { ...d, ...patch } : d)));
|
||||
}
|
||||
|
||||
function remove(idx: number) {
|
||||
commit(drafts.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function add() {
|
||||
setDrafts((prev) => [...prev, emptyDraft()]);
|
||||
// Don't commit yet - new row needs id+url before it counts.
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
const duplicateIdx = new Set<number>();
|
||||
drafts.forEach((d, i) => {
|
||||
const id = d.id.trim();
|
||||
if (!id) return;
|
||||
if (ids.has(id)) duplicateIdx.add(i);
|
||||
ids.add(id);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">Servers</span>
|
||||
{source && source !== 'default' && (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single <code className="text-[11px]">JMAP Server URL</code> above.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{source === 'admin' && (
|
||||
<button
|
||||
onClick={onRevert}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Revert to default"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={add}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-xs text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground italic">No servers configured.</div>
|
||||
)}
|
||||
|
||||
{drafts.map((d, i) => {
|
||||
const isDuplicate = duplicateIdx.has(i);
|
||||
return (
|
||||
<div key={i} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.id}
|
||||
onChange={(e) => update(i, { id: e.target.value })}
|
||||
placeholder="main"
|
||||
className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isDuplicate ? 'border-destructive' : 'border-input'}`}
|
||||
/>
|
||||
{isDuplicate && <p className="text-[10px] text-destructive mt-0.5">Duplicate id</p>}
|
||||
</div>
|
||||
<div className="sm:col-span-4">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.label}
|
||||
onChange={(e) => update(i, { label: e.target.value })}
|
||||
placeholder="Main server"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-5">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">JMAP URL</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={d.url}
|
||||
onChange={(e) => update(i, { url: e.target.value })}
|
||||
placeholder="https://mail.example.com"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={() => remove(i)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
title="Remove server"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
|
||||
Email domains (comma-separated, used for auto-pick)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.domains}
|
||||
onChange={(e) => update(i, { domains: e.target.value })}
|
||||
placeholder="example.com, example.org"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => update(i, { oauthExpanded: !d.oauthExpanded })}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
{d.oauthExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
Per-server OAuth (optional, overrides global)
|
||||
</button>
|
||||
{d.oauthExpanded && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.oauthClientId}
|
||||
onChange={(e) => update(i, { oauthClientId: e.target.value })}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Issuer URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={d.oauthIssuerUrl}
|
||||
onChange={(e) => update(i, { oauthIssuerUrl: e.target.value })}
|
||||
placeholder="https://auth.example.com"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
value={d.oauthClientSecret}
|
||||
onChange={(e) => update(i, { oauthClientSecret: e.target.value })}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
// Sensitive keys (sessionSecret, oauthClientSecret) come back with
|
||||
// `value` omitted and `hasValue` set instead - the server never echoes
|
||||
// the raw secret to the client.
|
||||
value?: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
hasValue?: boolean;
|
||||
}
|
||||
|
||||
export function AuthTab() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => { fetchConfig(); }, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: unknown) {
|
||||
setEdits(prev => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): unknown {
|
||||
if (key in edits) return edits[key];
|
||||
return config[key]?.value;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Authentication settings saved.' });
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
await fetchConfig();
|
||||
}
|
||||
}
|
||||
|
||||
const [setupRunning, setSetupRunning] = useState(false);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [setupOrigin, setSetupOrigin] = useState('');
|
||||
const [setupIssuer, setSetupIssuer] = useState('');
|
||||
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
|
||||
|
||||
function openSetupDialog() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const origin = window.location.origin;
|
||||
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
|
||||
setSetupOrigin(origin);
|
||||
setSetupIssuer(jmapUrl || origin);
|
||||
setSetupOauthOnly(currentValue('oauthOnly') === true);
|
||||
setSetupOpen(true);
|
||||
}
|
||||
|
||||
async function handleAutoSetup() {
|
||||
setSetupRunning(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/oauth/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
origin: setupOrigin.trim().replace(/\/+$/, ''),
|
||||
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
|
||||
oauthOnly: setupOauthOnly,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
|
||||
});
|
||||
setEdits({});
|
||||
setSetupOpen(false);
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
|
||||
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
|
||||
} finally {
|
||||
setSetupRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
|
||||
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary shrink-0" />
|
||||
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
|
||||
Requires your Stalwart account to have admin permissions.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openSetupDialog}
|
||||
disabled={setupRunning}
|
||||
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{setupRunning ? 'Configuring…' : 'Set up automagically'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{setupOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="oauth-setup-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
|
||||
Webmail origin
|
||||
</label>
|
||||
<input
|
||||
id="setup-origin"
|
||||
type="url"
|
||||
value={setupOrigin}
|
||||
onChange={(e) => setSetupOrigin(e.target.value)}
|
||||
disabled={setupRunning}
|
||||
placeholder="https://webmail.example.com"
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback</code>) on Stalwart.
|
||||
</p>
|
||||
{!setupOriginValid && setupOrigin.length > 0 && (
|
||||
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
|
||||
Stalwart issuer URL
|
||||
</label>
|
||||
<input
|
||||
id="setup-issuer"
|
||||
type="url"
|
||||
value={setupIssuer}
|
||||
onChange={(e) => setSetupIssuer(e.target.value)}
|
||||
disabled={setupRunning}
|
||||
placeholder="https://mail.example.com"
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
|
||||
</p>
|
||||
{!setupIssuerValid && setupIssuer.length > 0 && (
|
||||
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||
)}
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupOauthOnly}
|
||||
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-input"
|
||||
disabled={setupRunning}
|
||||
/>
|
||||
Also enable “OAuth only” (hide password login)
|
||||
</label>
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
|
||||
<button
|
||||
onClick={() => setSetupOpen(false)}
|
||||
disabled={setupRunning}
|
||||
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoSetup}
|
||||
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{setupRunning ? 'Configuring…' : 'Configure'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Section title="OAuth / OpenID Connect">
|
||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
|
||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
|
||||
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
|
||||
</Section>
|
||||
|
||||
<Section title="Single Sign-On">
|
||||
<Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</Section>
|
||||
|
||||
<Section title="Session & Security">
|
||||
<Select label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="Allowed Frame Ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." />
|
||||
<Text label="Parent Origin" description="For embedded mode communication" configKey="parentOrigin" value={currentValue('parentOrigin') as string} source={config.parentOrigin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source?: string }) {
|
||||
if (!source || source === 'default') return null;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Text({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text' }: {
|
||||
label: string; description?: string; configKey: string; value: string; source?: string;
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
|
||||
className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ label, description, configKey, value, source, onChange, onRevert }: {
|
||||
label: string; description?: string; configKey: string; value: boolean; source?: string;
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={() => onChange(configKey, !value)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Select({ label, configKey, value, source, options, onChange, onRevert }: {
|
||||
label: string; configKey: string; value: string; source?: string; options: string[];
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-reac
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
value?: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
hasValue?: boolean;
|
||||
}
|
||||
|
||||
const IMAGE_FIELDS = [
|
||||
@@ -24,7 +25,7 @@ const TEXT_FIELDS = [
|
||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||
];
|
||||
|
||||
export default function AdminBrandingPage() {
|
||||
export function BrandingTab() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -92,13 +93,11 @@ export default function AdminBrandingPage() {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
|
||||
// Remove any pending URL edit for this slot since upload sets it
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[slot];
|
||||
return next;
|
||||
});
|
||||
// Update config to reflect the uploaded URL
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[slot]: { value: data.url, source: 'admin' },
|
||||
@@ -162,8 +161,8 @@ export default function AdminBrandingPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
|
||||
</div>
|
||||
@@ -193,22 +192,22 @@ export default function AdminBrandingPage() {
|
||||
<div className="divide-y divide-border">
|
||||
{IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder="Enter URL or upload a file"
|
||||
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||
@@ -245,7 +244,6 @@ export default function AdminBrandingPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Preview */}
|
||||
{currentValue(field.key) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
@@ -270,20 +268,20 @@ export default function AdminBrandingPage() {
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
|
||||
className="h-8 w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
@@ -22,15 +22,16 @@ interface ConfigData {
|
||||
devMode?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
export function DashboardTab() {
|
||||
const [status, setStatus] = useState<AdminStatus | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
||||
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
|
||||
const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [themeCount, setThemeCount] = useState(0);
|
||||
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
||||
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
||||
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,7 +39,7 @@ export default function AdminDashboardPage() {
|
||||
}, []);
|
||||
|
||||
async function fetchDashboardData() {
|
||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
|
||||
apiFetch('/api/admin/auth'),
|
||||
apiFetch('/api/admin/audit?limit=10'),
|
||||
apiFetch('/api/config'),
|
||||
@@ -46,6 +47,7 @@ export default function AdminDashboardPage() {
|
||||
apiFetch('/api/admin/plugins').catch(() => null),
|
||||
apiFetch('/api/admin/themes').catch(() => null),
|
||||
apiFetch('/api/admin/policy').catch(() => null),
|
||||
apiFetch('/api/admin/telemetry').catch(() => null),
|
||||
]);
|
||||
|
||||
if (statusRes.ok) setStatus(await statusRes.json());
|
||||
@@ -73,6 +75,12 @@ export default function AdminDashboardPage() {
|
||||
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
|
||||
setPolicyRuleCount(restrictionCount + disabledGates);
|
||||
}
|
||||
if (telemetryRes?.ok) {
|
||||
const telemetry = await telemetryRes.json();
|
||||
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
|
||||
setAccountCounts(telemetry.accountCounts);
|
||||
}
|
||||
}
|
||||
|
||||
if (configData?.jmapServerUrl) {
|
||||
try {
|
||||
@@ -88,7 +96,9 @@ export default function AdminDashboardPage() {
|
||||
const sources = await adminConfigRes.json();
|
||||
setConfigSources(sources);
|
||||
const sessionSecret = sources?.sessionSecret;
|
||||
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
|
||||
// Server redacts the raw value for sensitive keys; rely on hasValue,
|
||||
// which is false when unset or matching a known placeholder default.
|
||||
if (!sessionSecret?.hasValue) {
|
||||
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
||||
}
|
||||
const adminPassword = sources?.adminPassword;
|
||||
@@ -104,7 +114,6 @@ export default function AdminDashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
{/* Warnings */}
|
||||
{warnings.map((msg, i) => (
|
||||
<div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
||||
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
||||
@@ -112,19 +121,6 @@ export default function AdminDashboardPage() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{status && !status.lastLogin && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
||||
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-warning">First login detected</p>
|
||||
<p className="text-sm text-warning/80 mt-0.5">
|
||||
Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server Info */}
|
||||
<SettingsSection title="Server" description="Application and connection details">
|
||||
<SettingItem label="Application">
|
||||
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
||||
@@ -149,7 +145,6 @@ export default function AdminDashboardPage() {
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Features */}
|
||||
<SettingsSection title="Features" description="Enabled integrations and modules">
|
||||
<SettingItem label="Admin Panel" description="Administrative access to server configuration">
|
||||
<ToggleSwitch checked={!!status?.enabled} onChange={() => {}} disabled />
|
||||
@@ -165,7 +160,15 @@ export default function AdminDashboardPage() {
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Extensions */}
|
||||
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
|
||||
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
|
||||
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
|
||||
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
||||
<SettingItem label="Plugins">
|
||||
<span className="text-sm text-foreground">{pluginCount}</span>
|
||||
@@ -178,7 +181,6 @@ export default function AdminDashboardPage() {
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<SettingsSection title="Recent Activity" description="Latest administrative actions">
|
||||
{recentActivity.length === 0 ? (
|
||||
<div className="py-4 text-sm text-muted-foreground">
|
||||
@@ -5,7 +5,7 @@ import { RefreshCw } from 'lucide-react';
|
||||
import type { AuditEntry } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
export function LogsTab() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -33,8 +33,8 @@ export default function AdminLogsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
|
||||
</div>
|
||||
@@ -47,12 +47,11 @@ export default function AdminLogsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="h-8 w-full sm:w-auto rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">All actions</option>
|
||||
<option value="admin.login">Login</option>
|
||||
@@ -66,15 +65,41 @@ export default function AdminLogsPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="sm:hidden space-y-2">
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">Loading...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">No entries found</div>
|
||||
) : (
|
||||
entries.map((entry, i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground truncate">
|
||||
{entry.action}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{new Date(entry.ts).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-foreground break-words">
|
||||
{formatDetail(entry.detail)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">
|
||||
{entry.ip}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block border border-border rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Time</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Action</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">IP</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
@@ -110,7 +135,6 @@ export default function AdminLogsPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, ArrowUpCircle } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
|
||||
|
||||
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
|
||||
interface Extension {
|
||||
slug: string;
|
||||
@@ -17,6 +21,9 @@ interface Extension {
|
||||
minAppVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
installed: boolean;
|
||||
installedVersion: string | null;
|
||||
iconUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
author: {
|
||||
displayName: string;
|
||||
githubLogin: string;
|
||||
@@ -35,7 +42,7 @@ interface SearchResult {
|
||||
|
||||
type TypeFilter = 'all' | 'plugin' | 'theme';
|
||||
|
||||
export default function AdminMarketplacePage() {
|
||||
export function MarketplaceTab() {
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -81,7 +88,6 @@ export default function AdminMarketplacePage() {
|
||||
fetchExtensions();
|
||||
}, [fetchExtensions]);
|
||||
|
||||
// Debounced search
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
@@ -92,6 +98,15 @@ export default function AdminMarketplacePage() {
|
||||
}, [searchInput]);
|
||||
|
||||
async function handleInstall(ext: Extension) {
|
||||
if (ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion)) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: `"${ext.name}" requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const isUpdate = ext.installed;
|
||||
const targetVersion = ext.latestVersion || '1.0.0';
|
||||
setInstalling(ext.slug);
|
||||
setMessage(null);
|
||||
|
||||
@@ -101,7 +116,7 @@ export default function AdminMarketplacePage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: ext.slug,
|
||||
version: ext.latestVersion || '1.0.0',
|
||||
version: targetVersion,
|
||||
type: ext.type,
|
||||
}),
|
||||
});
|
||||
@@ -110,14 +125,22 @@ export default function AdminMarketplacePage() {
|
||||
|
||||
if (res.ok) {
|
||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` });
|
||||
// Mark as installed in the UI
|
||||
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e));
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: isUpdate
|
||||
? `"${ext.name}" updated to v${targetVersion}${warnings}`
|
||||
: `"${ext.name}" installed successfully${warnings}`,
|
||||
});
|
||||
setExtensions(prev => prev.map(e =>
|
||||
e.slug === ext.slug
|
||||
? { ...e, installed: true, installedVersion: targetVersion }
|
||||
: e,
|
||||
));
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||
setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
|
||||
} finally {
|
||||
setInstalling(null);
|
||||
}
|
||||
@@ -140,9 +163,8 @@ export default function AdminMarketplacePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search & Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
@@ -152,7 +174,7 @@ export default function AdminMarketplacePage() {
|
||||
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5">
|
||||
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
|
||||
{(['all', 'plugin', 'theme'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
@@ -169,7 +191,6 @@ export default function AdminMarketplacePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="border border-border rounded-lg p-12 text-center">
|
||||
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
@@ -186,7 +207,6 @@ export default function AdminMarketplacePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && !error && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
@@ -194,7 +214,6 @@ export default function AdminMarketplacePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && !error && extensions.length === 0 && (
|
||||
<div className="border border-border rounded-lg p-12 text-center">
|
||||
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
@@ -207,7 +226,6 @@ export default function AdminMarketplacePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extension Grid */}
|
||||
{!loading && !error && extensions.length > 0 && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@@ -224,7 +242,6 @@ export default function AdminMarketplacePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
<button
|
||||
@@ -262,14 +279,38 @@ function ExtensionCard({
|
||||
onInstall: () => void;
|
||||
}) {
|
||||
const isPlugin = extension.type === 'plugin';
|
||||
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||
const versionMismatch = !!extension.minAppVersion
|
||||
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
|
||||
const updateAvailable = extension.installed
|
||||
&& !!extension.installedVersion
|
||||
&& !!extension.latestVersion
|
||||
&& compareVersions(extension.latestVersion, extension.installedVersion) > 0
|
||||
&& !versionMismatch;
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||
<div className="p-4">
|
||||
{/* Header */}
|
||||
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||
{extension.bannerUrl && (
|
||||
<Link href={previewHref} className="block focus:outline-none">
|
||||
<img
|
||||
src={extension.bannerUrl}
|
||||
alt=""
|
||||
className="block h-24 w-full object-cover border-b border-border"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
|
||||
{isPlugin ? (
|
||||
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{extension.iconUrl ? (
|
||||
<img
|
||||
src={extension.iconUrl}
|
||||
alt=""
|
||||
className="w-10 h-10 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : isPlugin ? (
|
||||
<Puzzle className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<SwatchBook className="w-5 h-5 text-muted-foreground" />
|
||||
@@ -277,7 +318,9 @@ function ExtensionCard({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground truncate">{extension.name}</span>
|
||||
<span className="text-sm font-medium text-foreground truncate group-hover:underline">
|
||||
{extension.name}
|
||||
</span>
|
||||
{extension.featured && (
|
||||
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
||||
)}
|
||||
@@ -299,12 +342,10 @@ function ExtensionCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-muted-foreground mt-3 line-clamp-2">
|
||||
{extension.description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
{extension.tags && extension.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{extension.tags.slice(0, 3).map(tag => (
|
||||
@@ -315,7 +356,6 @@ function ExtensionCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -328,15 +368,47 @@ function ExtensionCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
|
||||
<Eye className="w-3 h-3" />
|
||||
Preview
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{extension.installed ? (
|
||||
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
||||
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
|
||||
{extension.installed && updateAvailable ? (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||
disabled={installing}
|
||||
title={`Update from v${extension.installedVersion} to v${extension.latestVersion}`}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{installing ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpCircle className="w-3 h-3" />
|
||||
)}
|
||||
Update to v{extension.latestVersion}
|
||||
</button>
|
||||
) : extension.installed ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium"
|
||||
title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
Installed
|
||||
</span>
|
||||
) : versionMismatch ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300 text-xs font-medium"
|
||||
title={`Requires app v${extension.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Requires v{extension.minAppVersion}+
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onInstall}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||
disabled={installing}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
@@ -345,11 +417,10 @@ function ExtensionCard({
|
||||
) : (
|
||||
<Download className="w-3 h-3" />
|
||||
)}
|
||||
Install
|
||||
Quick install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
|
||||
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||
|
||||
interface ConfigField {
|
||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||
@@ -32,9 +32,12 @@ interface PluginInfo {
|
||||
configSchema?: Record<string, ConfigField>;
|
||||
}
|
||||
|
||||
export default function PluginConfigPage() {
|
||||
const params = useParams();
|
||||
const pluginId = params.id as string;
|
||||
interface Props {
|
||||
pluginId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
|
||||
const [config, setConfig] = useState<PluginConfig>({});
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||
@@ -44,11 +47,32 @@ export default function PluginConfigPage() {
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function fetchData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [pluginsRes, configRes] = await Promise.all([
|
||||
apiFetch('/api/admin/plugins'),
|
||||
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
if (pluginsRes.ok) {
|
||||
const plugins: PluginInfo[] = await pluginsRes.json();
|
||||
setPlugin(plugins.find(p => p.id === pluginId) || null);
|
||||
}
|
||||
|
||||
if (configRes.ok) {
|
||||
setConfig(await configRes.json());
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return () => { cancelled = true; };
|
||||
}, [pluginId]);
|
||||
|
||||
// Initialize form values from config + schema defaults when data loads
|
||||
useEffect(() => {
|
||||
if (!plugin?.configSchema) return;
|
||||
const initial: Record<string, string> = {};
|
||||
@@ -65,33 +89,11 @@ export default function PluginConfigPage() {
|
||||
setFormValues(initial);
|
||||
}, [plugin, config]);
|
||||
|
||||
async function fetchData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [pluginsRes, configRes] = await Promise.all([
|
||||
apiFetch('/api/admin/plugins'),
|
||||
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
||||
]);
|
||||
|
||||
if (pluginsRes.ok) {
|
||||
const plugins: PluginInfo[] = await pluginsRes.json();
|
||||
setPlugin(plugins.find(p => p.id === pluginId) || null);
|
||||
}
|
||||
|
||||
if (configRes.ok) {
|
||||
setConfig(await configRes.json());
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAll() {
|
||||
if (!plugin?.configSchema) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
// Validate required fields
|
||||
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||
if (field.required && !formValues[key]?.trim()) {
|
||||
setMessage({ type: 'error', text: `"${field.label}" is required` });
|
||||
@@ -101,22 +103,18 @@ export default function PluginConfigPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Save each changed field
|
||||
let hasError = false;
|
||||
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||
const newVal = formValues[key] ?? '';
|
||||
const oldVal = config[key] !== undefined ? String(config[key]) : '';
|
||||
|
||||
// Skip unchanged fields (and skip secret fields that show as empty when they have a stored value)
|
||||
if (newVal === oldVal) continue;
|
||||
if (field.type === 'secret' && !newVal && config[key]) continue;
|
||||
|
||||
// Convert types
|
||||
let value: unknown = newVal;
|
||||
if (field.type === 'boolean') value = newVal === 'true';
|
||||
else if (field.type === 'number') value = Number(newVal);
|
||||
|
||||
// Delete if clearing a non-required field
|
||||
if (!newVal && !field.required) {
|
||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||
method: 'DELETE',
|
||||
@@ -166,9 +164,13 @@ export default function PluginConfigPage() {
|
||||
if (!plugin) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Plugins
|
||||
</Link>
|
||||
</button>
|
||||
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -180,9 +182,14 @@ export default function PluginConfigPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
aria-label="Back to Plugins"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Link>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<Puzzle className="w-5 h-5" />
|
||||
@@ -281,6 +288,27 @@ export default function PluginConfigPage() {
|
||||
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginAdminSection pluginId={pluginId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the plugin's own `admin-plugin-page` slot, if the plugin offers
|
||||
* one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']`
|
||||
* and the host gives it a dedicated iframe inside the admin panel.
|
||||
*/
|
||||
function PluginAdminSection({ pluginId }: { pluginId: string }) {
|
||||
const offers = usePluginSlotOffers('admin-plugin-page');
|
||||
const offer = offers.find((o) => o.pluginId === pluginId);
|
||||
if (!offer) return null;
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Plugin admin panel
|
||||
</div>
|
||||
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { PluginConfigPanel } from './plugin-config-panel';
|
||||
|
||||
interface PluginEntry {
|
||||
id: string;
|
||||
@@ -21,7 +21,7 @@ interface PluginEntry {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function AdminPluginsPage() {
|
||||
export function PluginsTab() {
|
||||
const [plugins, setPlugins] = useState<PluginEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -30,6 +30,7 @@ export default function AdminPluginsPage() {
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [policyDirty, setPolicyDirty] = useState(false);
|
||||
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||
const [configuringId, setConfiguringId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
|
||||
|
||||
@@ -153,7 +154,6 @@ export default function AdminPluginsPage() {
|
||||
|
||||
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
||||
setMessage(null);
|
||||
// If force-enabling, also ensure the plugin is enabled
|
||||
const body: Record<string, unknown> = { id, forceEnabled };
|
||||
if (forceEnabled) body.enabled = true;
|
||||
|
||||
@@ -165,7 +165,6 @@ export default function AdminPluginsPage() {
|
||||
|
||||
if (res.ok) {
|
||||
setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p));
|
||||
// Also update policy
|
||||
setPolicy(prev => {
|
||||
const current = prev.forceEnabledPlugins || [];
|
||||
return {
|
||||
@@ -252,6 +251,10 @@ export default function AdminPluginsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (configuringId) {
|
||||
return <PluginConfigPanel pluginId={configuringId} onBack={() => { setConfiguringId(null); fetchPlugins(); }} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
@@ -262,12 +265,12 @@ export default function AdminPluginsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{policyDirty && (
|
||||
<button
|
||||
onClick={handleSavePolicy}
|
||||
@@ -299,7 +302,6 @@ export default function AdminPluginsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugin Policy */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -309,7 +311,7 @@ export default function AdminPluginsPage() {
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Plugins Enabled</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
|
||||
@@ -320,7 +322,7 @@ export default function AdminPluginsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">User Plugin Uploads</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
|
||||
@@ -331,7 +333,7 @@ export default function AdminPluginsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Require Admin Approval</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
|
||||
@@ -342,9 +344,8 @@ export default function AdminPluginsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Force enable / disable all */}
|
||||
{plugins.length > 0 && (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
|
||||
@@ -370,7 +371,6 @@ export default function AdminPluginsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deployed Plugins */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -388,9 +388,9 @@ export default function AdminPluginsPage() {
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{plugins.map(plugin => (
|
||||
<div key={plugin.id} className="px-4 py-4 flex items-center justify-between gap-4">
|
||||
<div key={plugin.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
|
||||
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
@@ -419,13 +419,14 @@ export default function AdminPluginsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/admin/plugins/${plugin.id}`}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfiguringId(plugin.id)}
|
||||
title="Configure"
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Link>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
||||
@@ -6,7 +6,6 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
// Feature gates managed on their own admin pages (excluded from this list)
|
||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||
|
||||
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||
@@ -15,6 +14,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||
@@ -30,7 +30,7 @@ const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
|
||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
|
||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||
@@ -43,7 +43,7 @@ const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
|
||||
];
|
||||
|
||||
export default function AdminPolicyPage() {
|
||||
export function PolicyTab() {
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -131,8 +131,8 @@ export default function AdminPolicyPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
|
||||
</div>
|
||||
@@ -154,7 +154,6 @@ export default function AdminPolicyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature Gates */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Feature Gates</h2>
|
||||
@@ -169,13 +168,13 @@ export default function AdminPolicyPage() {
|
||||
const { label, description } = meta;
|
||||
const enabled = policy.features[key];
|
||||
return (
|
||||
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div key={key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
<button onClick={() => toggleFeature(key)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -184,7 +183,6 @@ export default function AdminPolicyPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setting Restrictions */}
|
||||
{categories.map(category => (
|
||||
<div key={category} className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
@@ -194,9 +192,9 @@ export default function AdminPolicyPage() {
|
||||
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
|
||||
const restriction = policy.restrictions[setting.key] || {};
|
||||
return (
|
||||
<div key={setting.key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div key={setting.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-foreground">{setting.label}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
|
||||
className="rounded border-input" />
|
||||
@@ -3,13 +3,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { JmapServersSection } from './_jmap-servers-section';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
value?: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
hasValue?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
export function SettingsTab() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -86,8 +89,8 @@ export default function AdminSettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
|
||||
</div>
|
||||
@@ -109,7 +112,6 @@ export default function AdminSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* General */}
|
||||
<SettingsSection title="General">
|
||||
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||
@@ -122,17 +124,39 @@ export default function AdminSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
{/* Logging */}
|
||||
<SettingsSection title="JMAP Servers (multi-server)">
|
||||
<ToggleSetting
|
||||
label="Auto-pick server by email domain"
|
||||
description="When users type their email, automatically select the matching server from the list below."
|
||||
configKey="jmapServerAutoPickByDomain"
|
||||
value={currentValue('jmapServerAutoPickByDomain') as boolean}
|
||||
source={config.jmapServerAutoPickByDomain?.source}
|
||||
onChange={handleChange}
|
||||
onRevert={handleRevert}
|
||||
/>
|
||||
<JmapServersSection
|
||||
value={(currentValue('jmapServers') as JmapServerEntry[]) ?? []}
|
||||
source={config.jmapServers?.source}
|
||||
onChange={(next) => handleChange('jmapServers', next)}
|
||||
onRevert={() => handleRevert('jmapServers')}
|
||||
/>
|
||||
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Logging">
|
||||
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
{/* Settings Sync */}
|
||||
<SettingsSection title="Settings Sync">
|
||||
<ToggleSetting label="Settings Sync Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
@@ -167,21 +191,21 @@ function TextSetting({ label, configKey, value, source, onChange, onRevert, plac
|
||||
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground whitespace-nowrap">{label}</label>
|
||||
<label className="text-sm text-foreground">{label}</label>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(configKey, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
@@ -195,7 +219,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
|
||||
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
@@ -203,7 +227,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => onChange(configKey, !value)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
|
||||
@@ -225,12 +249,12 @@ function SelectSetting({ label, configKey, value, source, options, onChange, onR
|
||||
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(configKey, e.target.value)}
|
||||
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface TelemetryStatus {
|
||||
consent: 'pending' | 'on' | 'off';
|
||||
consentSource: 'env' | 'file';
|
||||
endpoint: string;
|
||||
defaultEndpoint: string;
|
||||
consentedAt: string | null;
|
||||
lastSentAt: string | null;
|
||||
nextScheduledAt: string | null;
|
||||
payloadPreview: Record<string, unknown>;
|
||||
accountCounts: { total: number; active7d: number };
|
||||
}
|
||||
|
||||
function timeAgo(iso: string | null): string {
|
||||
if (!iso) return 'never';
|
||||
const d = Date.now() - new Date(iso).getTime();
|
||||
if (d < 0) return new Date(iso).toLocaleString();
|
||||
const m = Math.floor(d / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m} min ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 48) return `${h} hours ago`;
|
||||
const days = Math.floor(h / 24);
|
||||
return `${days} days ago`;
|
||||
}
|
||||
|
||||
export function TelemetryTab() {
|
||||
const [status, setStatus] = useState<TelemetryStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [endpointDraft, setEndpointDraft] = useState('');
|
||||
const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/telemetry');
|
||||
if (!r.ok) throw new Error('failed to load');
|
||||
const data = (await r.json()) as TelemetryStatus;
|
||||
setStatus(data);
|
||||
setEndpointDraft(data.endpoint);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => { void refresh(); }, []);
|
||||
|
||||
async function setConsent(consent: 'on' | 'off'): Promise<void> {
|
||||
setBusy('consent');
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set-consent', consent }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||
alert(j.error ?? 'failed');
|
||||
}
|
||||
await refresh();
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
|
||||
async function saveEndpoint(): Promise<void> {
|
||||
setBusy('endpoint');
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||
alert(j.error ?? 'failed');
|
||||
}
|
||||
await refresh();
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
|
||||
async function sendNow(): Promise<void> {
|
||||
setBusy('send');
|
||||
setSendResult(null);
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'send-now' }),
|
||||
});
|
||||
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
|
||||
setSendResult({
|
||||
ok: !!j.ok,
|
||||
msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`,
|
||||
});
|
||||
await refresh();
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
|
||||
if (loading || !status) {
|
||||
return (
|
||||
<div className="p-8 flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const envOverridden = status.consentSource === 'env';
|
||||
const isOn = status.consent === 'on';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
|
||||
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
|
||||
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
|
||||
<a
|
||||
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline inline-flex items-center gap-1"
|
||||
>
|
||||
Full schema and policy <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">Status</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
||||
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
|
||||
{status.consent === 'off' && 'Heartbeats are off.'}
|
||||
{envOverridden && (
|
||||
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === 'consent' || envOverridden || isOn}
|
||||
onClick={() => void setConsent('on')}
|
||||
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === 'consent' || envOverridden || status.consent === 'off'}
|
||||
onClick={() => void setConsent('off')}
|
||||
className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm pt-2 border-t">
|
||||
<dt className="text-muted-foreground">Last sent</dt>
|
||||
<dd>{timeAgo(status.lastSentAt)}</dd>
|
||||
<dt className="text-muted-foreground">Next scheduled</dt>
|
||||
<dd>{timeAgo(status.nextScheduledAt)}</dd>
|
||||
<dt className="text-muted-foreground">Consented at</dt>
|
||||
<dd>{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border p-4 space-y-2">
|
||||
<div className="font-medium">Account activity</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unique accounts that have logged in over the last 90 days. Identities are stored as a
|
||||
per-instance HMAC, never as plaintext usernames. These are the numbers reported in the
|
||||
heartbeat as bucketed ranges.
|
||||
</p>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm pt-1">
|
||||
<dt className="text-muted-foreground">Total (90d)</dt>
|
||||
<dd className="font-mono">{status.accountCounts?.total ?? 0}</dd>
|
||||
<dt className="text-muted-foreground">Active (7d)</dt>
|
||||
<dd className="font-mono">{status.accountCounts?.active7d ?? 0}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border p-4 space-y-3">
|
||||
<div className="font-medium">Endpoint</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
|
||||
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={endpointDraft}
|
||||
onChange={(e) => setEndpointDraft(e.target.value)}
|
||||
placeholder={status.defaultEndpoint}
|
||||
className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
|
||||
onClick={() => void saveEndpoint()}
|
||||
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center justify-center gap-1"
|
||||
>
|
||||
<Save className="h-4 w-4" /> Save
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">Payload preview</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Exactly what the next heartbeat would send from this install, right now.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === 'send' || !isOn}
|
||||
onClick={() => void sendNow()}
|
||||
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
|
||||
>
|
||||
<Send className="h-4 w-4" /> Send now
|
||||
</button>
|
||||
</div>
|
||||
{sendResult && (
|
||||
<div
|
||||
className={`text-sm flex items-center gap-2 ${
|
||||
sendResult.ok ? 'text-emerald-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{sendResult.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||
{sendResult.msg}
|
||||
</div>
|
||||
)}
|
||||
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
|
||||
{JSON.stringify(status.payloadPreview, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ interface ThemeEntry {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function AdminThemesPage() {
|
||||
export function ThemesTab() {
|
||||
const [themes, setThemes] = useState<ThemeEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -307,12 +307,12 @@ export default function AdminThemesPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{policyDirty && (
|
||||
<button
|
||||
onClick={handleSavePolicy}
|
||||
@@ -344,7 +344,6 @@ export default function AdminThemesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme Policy */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -355,38 +354,35 @@ export default function AdminThemesPage() {
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{/* Master toggle */}
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Themes Enabled</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
|
||||
</div>
|
||||
<button onClick={toggleThemesEnabled}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User uploads toggle */}
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">User Theme Uploads</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
|
||||
</div>
|
||||
<button onClick={toggleUserThemeUploads}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Force enable / disable all */}
|
||||
{themes.length > 0 && (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={forceEnableAll}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
|
||||
@@ -405,17 +401,16 @@ export default function AdminThemesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default Theme */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Default Theme</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
|
||||
</div>
|
||||
<select
|
||||
value={policy.themePolicy?.defaultThemeId || ''}
|
||||
onChange={(e) => setDefaultTheme(e.target.value || null)}
|
||||
className="h-8 px-2 rounded-md border border-input bg-background text-sm text-foreground"
|
||||
className="h-8 px-2 w-full sm:w-auto shrink-0 rounded-md border border-input bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="">System Default</option>
|
||||
<optgroup label="Built-in">
|
||||
@@ -438,7 +433,6 @@ export default function AdminThemesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Built-in themes */}
|
||||
<div className="px-4 py-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Built-in Themes</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
@@ -457,7 +451,6 @@ export default function AdminThemesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin-deployed themes */}
|
||||
{themes.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Admin-deployed Themes</span>
|
||||
@@ -480,7 +473,6 @@ export default function AdminThemesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deployed Themes */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -498,9 +490,9 @@ export default function AdminThemesPage() {
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{themes.map(theme => (
|
||||
<div key={theme.id} className="px-4 py-4 flex items-center justify-between gap-4">
|
||||
<div key={theme.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-medium text-foreground">{theme.name}</span>
|
||||
<span className="text-xs text-muted-foreground">v{theme.version}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
ShieldAlert,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
|
||||
|
||||
interface VersionAdminStatus {
|
||||
current: string;
|
||||
build: string;
|
||||
endpoint: string;
|
||||
defaultEndpoint: string;
|
||||
disabledByEnv: boolean;
|
||||
lastCheckedAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
nextScheduledAt: string | null;
|
||||
status: UpdateStatus | null;
|
||||
}
|
||||
|
||||
function timeAgo(iso: string | null): string {
|
||||
if (!iso) return 'never';
|
||||
const d = Date.now() - new Date(iso).getTime();
|
||||
if (d < 0) return new Date(iso).toLocaleString();
|
||||
const m = Math.floor(d / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m} min ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 48) return `${h} hours ago`;
|
||||
return `${Math.floor(h / 24)} days ago`;
|
||||
}
|
||||
|
||||
function severityChip(severity: UpdateSeverity) {
|
||||
switch (severity) {
|
||||
case 'security':
|
||||
return {
|
||||
label: 'Security update',
|
||||
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
|
||||
Icon: ShieldAlert,
|
||||
};
|
||||
case 'deprecated':
|
||||
return {
|
||||
label: 'Deprecated',
|
||||
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
|
||||
Icon: ShieldAlert,
|
||||
};
|
||||
case 'normal':
|
||||
return {
|
||||
label: 'Update available',
|
||||
className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30',
|
||||
Icon: AlertTriangle,
|
||||
};
|
||||
case 'unknown':
|
||||
return {
|
||||
label: 'Unknown',
|
||||
className: 'bg-muted text-muted-foreground border-border',
|
||||
Icon: AlertTriangle,
|
||||
};
|
||||
case 'none':
|
||||
default:
|
||||
return {
|
||||
label: 'Up to date',
|
||||
className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30',
|
||||
Icon: CheckCircle2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function VersionTab() {
|
||||
const [data, setData] = useState<VersionAdminStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/version');
|
||||
if (!r.ok) throw new Error('failed to load');
|
||||
setData((await r.json()) as VersionAdminStatus);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => { void refresh(); }, []);
|
||||
|
||||
async function checkNow(): Promise<void> {
|
||||
setChecking(true);
|
||||
setCheckResult(null);
|
||||
try {
|
||||
const r = await apiFetch('/api/admin/version', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'check-now' }),
|
||||
});
|
||||
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
|
||||
setCheckResult({
|
||||
ok: !!j.ok,
|
||||
msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`,
|
||||
});
|
||||
await refresh();
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="p-8 flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const status = data.status;
|
||||
const chip = severityChip(status?.severity ?? 'none');
|
||||
const ChipIcon = chip.Icon;
|
||||
const releaseUrl = status?.url ?? null;
|
||||
const newer = status?.latest && status.latest !== data.current ? status.latest : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Version</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Hourly check against the Bulwark version server. Severity is decided server-side and
|
||||
disable with <code>BULWARK_UPDATE_CHECK=off</code>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={checking}
|
||||
onClick={() => void checkNow()}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
Check now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{checkResult && (
|
||||
<div
|
||||
className={`text-sm rounded-md px-3 py-2 ${
|
||||
checkResult.ok
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{checkResult.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Status">
|
||||
<SettingItem label="Severity">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${chip.className}`}
|
||||
>
|
||||
<ChipIcon className="h-3 w-3" />
|
||||
{chip.label}
|
||||
</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
|
||||
<span className="text-sm font-mono text-foreground">{data.current}</span>
|
||||
</SettingItem>
|
||||
{newer && (
|
||||
<SettingItem label="Latest release">
|
||||
{releaseUrl ? (
|
||||
<a
|
||||
href={releaseUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
|
||||
>
|
||||
{newer} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-mono text-foreground">{newer}</span>
|
||||
)}
|
||||
</SettingItem>
|
||||
)}
|
||||
{status?.advisory && (
|
||||
<SettingItem label="Advisory">
|
||||
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Schedule" description="Hourly polling with ±5 minute jitter.">
|
||||
<SettingItem label="Last checked">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Last success">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Next scheduled">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
|
||||
</SettingItem>
|
||||
{status?.checkedAt && (
|
||||
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
|
||||
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Source">
|
||||
<SettingItem
|
||||
label="Endpoint"
|
||||
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
|
||||
>
|
||||
<a
|
||||
href={data.endpoint}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
|
||||
>
|
||||
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
|
||||
</a>
|
||||
</SettingItem>
|
||||
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
|
||||
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
|
||||
{data.disabledByEnv ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=auth');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=branding');
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAdminTabStore, type AdminTabId } from '@/stores/admin-tab-store';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Palette,
|
||||
Shield,
|
||||
Scale,
|
||||
ScrollText,
|
||||
LogOut,
|
||||
KeyRound,
|
||||
Puzzle,
|
||||
SwatchBook,
|
||||
Activity,
|
||||
Package,
|
||||
Mail,
|
||||
Calendar,
|
||||
BookUser,
|
||||
HardDrive,
|
||||
Store,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
||||
|
||||
// Single-page tab navigation: clicks update a Zustand store. The URL stays
|
||||
// at /admin so React doesn't fire a route transition on every tab switch -
|
||||
// matches the regular settings page pattern, fixes the dev-mode "Rendering…"
|
||||
// hang we saw with both /admin/<segment> routes and ?tab= search params.
|
||||
const NAV_GROUPS: ReadonlyArray<{
|
||||
label: string;
|
||||
items: ReadonlyArray<{ tab: AdminTabId; label: string; icon: typeof LayoutDashboard }>;
|
||||
}> = [
|
||||
{
|
||||
label: 'Overview',
|
||||
items: [
|
||||
{ tab: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Configuration',
|
||||
items: [
|
||||
{ tab: 'settings', label: 'Settings', icon: Settings },
|
||||
{ tab: 'branding', label: 'Branding', icon: Palette },
|
||||
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
||||
{ tab: 'policy', label: 'Policy', icon: Scale },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Extensions',
|
||||
items: [
|
||||
{ tab: 'plugins', label: 'Plugins', icon: Puzzle },
|
||||
{ tab: 'themes', label: 'Themes', icon: SwatchBook },
|
||||
{ tab: 'marketplace', label: 'Marketplace', icon: Store },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
{ tab: 'version', label: 'Version', icon: Package },
|
||||
{ tab: 'telemetry', label: 'Telemetry', icon: Activity },
|
||||
{ tab: 'logs', label: 'Audit Log', icon: ScrollText },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const storeActiveTab = useAdminTabStore((s) => s.activeTab);
|
||||
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
|
||||
// Highlight the active tab only on /admin itself - on dynamic routes
|
||||
// (e.g. /admin/plugins/[id]) no tab is "current".
|
||||
const activeTab = pathname === '/admin' ? storeActiveTab : null;
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||
|
||||
// Match the navigation rail: red for security/deprecated, amber for normal.
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
const updateSeverity = useUpdateStore((s) => s.status?.severity);
|
||||
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
|
||||
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
|
||||
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
|
||||
|
||||
useEffect(() => {
|
||||
setMobileNavOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileNavOpen) return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [mobileNavOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === '/admin/login') return;
|
||||
let cancelled = false;
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const jmapHeaders = getActiveAccountSlotHeaders();
|
||||
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
|
||||
const stalwartAdmin = data.stalwartAdmin === true;
|
||||
setIsStalwartAdmin(stalwartAdmin);
|
||||
|
||||
// If neither password-based admin nor Stalwart admin, redirect away
|
||||
if (!data.enabled && !stalwartAdmin) {
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.authenticated) {
|
||||
setAuthenticated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If Stalwart admin but not yet authenticated, auto-login
|
||||
if (stalwartAdmin) {
|
||||
const loginRes = await apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||
body: JSON.stringify({ stalwartAuth: true }),
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (loginRes.ok) {
|
||||
setAuthenticated(true);
|
||||
return;
|
||||
}
|
||||
const body = await loginRes.json().catch(() => ({}));
|
||||
setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`);
|
||||
setAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace('/admin/login');
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
|
||||
setAuthenticated(false);
|
||||
}
|
||||
}
|
||||
|
||||
checkAuth();
|
||||
return () => { cancelled = true; };
|
||||
}, [pathname, router]);
|
||||
|
||||
async function handleLogout() {
|
||||
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
||||
router.replace('/admin/login');
|
||||
}
|
||||
|
||||
// Don't gate the login page
|
||||
if (pathname === '/admin/login') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// /admin lives outside the [locale] tree, so links back to the webmail
|
||||
// apps are bare <a> tags (hard navigation). Next.js only auto-applies
|
||||
// basePath to <Link>/router APIs - for these we prepend it manually so
|
||||
// NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/".
|
||||
const prefix = getPathPrefix();
|
||||
|
||||
const navContent = (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<div className="px-2 space-y-0.5">
|
||||
{NAV_GROUPS.map((group, groupIndex) => (
|
||||
<div key={group.label}>
|
||||
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.items.map(({ tab, label, icon: Icon }) => {
|
||||
const active = activeTab === tab;
|
||||
const showDot = tab === 'version' && hasUpdate;
|
||||
const handleClick = () => {
|
||||
setActiveTab(tab);
|
||||
// From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug])
|
||||
// we still need a real navigation back to /admin so the page renders.
|
||||
if (pathname !== '/admin') router.push('/admin');
|
||||
};
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="relative shrink-0">
|
||||
<Icon className={cn(
|
||||
'w-4 h-4',
|
||||
active ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
{showDot && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2',
|
||||
active ? 'ring-accent' : 'ring-background',
|
||||
updateImportant ? 'bg-red-500' : 'bg-amber-500',
|
||||
)}
|
||||
aria-label={updateImportant ? 'Important update available' : 'Update available'}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
|
||||
{!isStalwartAdmin && (
|
||||
<Link
|
||||
href="/admin/change-password"
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
pathname === '/admin/change-password'
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<KeyRound className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
Change Password
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
{/* Slim webmail nav rail (desktop only) */}
|
||||
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||
) : (
|
||||
<div className="w-7 h-7 mb-2" />
|
||||
)}
|
||||
<a
|
||||
href={`${prefix}/`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Mail"
|
||||
>
|
||||
<Mail className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href={`${prefix}/calendar`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Calendar"
|
||||
>
|
||||
<Calendar className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href={`${prefix}/contacts`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
{filesEnabled && (
|
||||
<a
|
||||
href={`${prefix}/files`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
)}
|
||||
<div className="mt-auto flex flex-col items-center gap-2">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
|
||||
<Shield className="w-[18px] h-[18px]" />
|
||||
</div>
|
||||
<a
|
||||
href={`${prefix}/settings`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Admin Sidebar (desktop only) */}
|
||||
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
|
||||
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||
</div>
|
||||
{navContent}
|
||||
</aside>
|
||||
|
||||
{/* Mobile drawer overlay */}
|
||||
{mobileNavOpen && (
|
||||
<div
|
||||
className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<aside
|
||||
className={cn(
|
||||
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
||||
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
aria-label="Admin navigation"
|
||||
aria-hidden={!mobileNavOpen}
|
||||
>
|
||||
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center min-w-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{navContent}
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 overflow-x-hidden">
|
||||
{/* Mobile header */}
|
||||
<div className="md:hidden sticky top-0 z-30 h-14 flex items-center gap-2 px-3 border-b border-border bg-background">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileNavOpen(true)}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-md text-foreground hover:bg-muted transition-colors"
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center min-w-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-6 pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6">
|
||||
{authError ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<p className="font-medium">Admin authentication failed</p>
|
||||
<p className="mt-1 text-destructive/80">{authError}</p>
|
||||
</div>
|
||||
) : authenticated === null ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
|
||||
Loading admin panel…
|
||||
</div>
|
||||
) : authenticated ? (
|
||||
children
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile bottom nav (main webmail nav) */}
|
||||
<nav
|
||||
className="md:hidden fixed inset-x-0 bottom-0 z-30 flex items-center bg-background border-t border-border pb-[env(safe-area-inset-bottom)]"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
href={`${prefix}/`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Mail"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
|
||||
</a>
|
||||
<a
|
||||
href={`${prefix}/calendar`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Calendar"
|
||||
>
|
||||
<Calendar className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
|
||||
</a>
|
||||
<a
|
||||
href={`${prefix}/contacts`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
|
||||
</a>
|
||||
{filesEnabled && (
|
||||
<a
|
||||
href={`${prefix}/files`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
|
||||
</a>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
|
||||
title="Admin"
|
||||
aria-current="page"
|
||||
>
|
||||
<div className="relative">
|
||||
<Shield className="w-5 h-5" />
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
|
||||
</div>
|
||||
<a
|
||||
href={`${prefix}/settings`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Settings</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,13 +47,13 @@ export default function AdminLoginPage() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
|
||||
<img src={logoUrl} alt="" className="h-12 object-contain mb-4" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Shield className="w-6 h-6 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=logs');
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUpCircle,
|
||||
Download,
|
||||
Loader2,
|
||||
Puzzle,
|
||||
SwatchBook,
|
||||
Star,
|
||||
Trash2,
|
||||
Check,
|
||||
Settings as SettingsIcon,
|
||||
ExternalLink,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
FileCode,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
|
||||
|
||||
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
|
||||
interface PreviewData {
|
||||
extension: {
|
||||
slug: string;
|
||||
name: string;
|
||||
type: 'plugin' | 'theme';
|
||||
pluginType: string | null;
|
||||
description: string;
|
||||
longDescription: string | null;
|
||||
tags: string[];
|
||||
permissions: string[];
|
||||
totalDownloads: number;
|
||||
featured: boolean;
|
||||
githubRepo: string | null;
|
||||
license: string | null;
|
||||
minAppVersion: string | null;
|
||||
iconUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
author: {
|
||||
displayName: string;
|
||||
githubLogin: string;
|
||||
avatarUrl: string | null;
|
||||
verified?: boolean;
|
||||
} | null;
|
||||
latestVersion: string | null;
|
||||
versions: Array<{
|
||||
version: string;
|
||||
changelog: string | null;
|
||||
bundleSize: number;
|
||||
minAppVersion: string | null;
|
||||
publishedAt: string | null;
|
||||
permissions: string[];
|
||||
}>;
|
||||
screenshots: Array<{ url: string; altText: string | null }>;
|
||||
themePreviews: Array<{
|
||||
variant: 'light' | 'dark';
|
||||
previewPath: string;
|
||||
colors: Record<string, string> | null;
|
||||
}>;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
bundle: {
|
||||
manifest: Record<string, unknown> | null;
|
||||
source: { name: string; content: string; truncated: boolean } | null;
|
||||
size: number;
|
||||
error: string | null;
|
||||
};
|
||||
installed: boolean;
|
||||
installedVersion: string | null;
|
||||
}
|
||||
|
||||
const RISKY_PERMISSIONS = new Set([
|
||||
'mail:write',
|
||||
'mail:delete',
|
||||
'storage:write',
|
||||
'network',
|
||||
'admin',
|
||||
]);
|
||||
|
||||
export default function MarketplacePreviewPage() {
|
||||
const params = useParams();
|
||||
const slug = params.slug as string;
|
||||
|
||||
const [data, setData] = useState<PreviewData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [uninstalling, setUninstalling] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [showManifest, setShowManifest] = useState(false);
|
||||
|
||||
const fetchPreview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setError(body.error || 'Failed to load preview');
|
||||
return;
|
||||
}
|
||||
setData(await res.json());
|
||||
} catch {
|
||||
setError('Failed to connect to extension directory');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => { fetchPreview(); }, [fetchPreview]);
|
||||
|
||||
async function handleInstall() {
|
||||
if (!data) return;
|
||||
const isUpdate = data.installed;
|
||||
const targetVersion = data.extension.latestVersion || '1.0.0';
|
||||
setInstalling(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/marketplace', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: data.extension.slug,
|
||||
version: targetVersion,
|
||||
type: data.extension.type,
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (res.ok) {
|
||||
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: isUpdate
|
||||
? `"${data.extension.name}" updated to v${targetVersion}${warnings}`
|
||||
: `"${data.extension.name}" installed${warnings}`,
|
||||
});
|
||||
setData(prev => prev ? { ...prev, installed: true, installedVersion: targetVersion } : prev);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUninstall() {
|
||||
if (!data) return;
|
||||
if (!confirm(`Remove "${data.extension.name}"? This cannot be undone.`)) return;
|
||||
|
||||
setUninstalling(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const endpoint = data.extension.type === 'theme'
|
||||
? '/api/admin/themes'
|
||||
: '/api/admin/plugins';
|
||||
const res = await apiFetch(endpoint, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: data.extension.slug }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: `"${data.extension.name}" removed` });
|
||||
setData(prev => prev ? { ...prev, installed: false } : prev);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: body.error || 'Uninstall failed' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Uninstall failed - network error' });
|
||||
} finally {
|
||||
setUninstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
href="/admin/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
||||
</Link>
|
||||
<p className="text-sm text-destructive">{error || 'Extension not found'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ext = data.extension;
|
||||
const bundle = data.bundle;
|
||||
const isPlugin = ext.type === 'plugin';
|
||||
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
||||
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
||||
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
||||
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
|
||||
const updateAvailable = data.installed
|
||||
&& !!data.installedVersion
|
||||
&& !!ext.latestVersion
|
||||
&& compareVersions(ext.latestVersion, data.installedVersion) > 0
|
||||
&& !versionMismatch;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/admin/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
||||
</Link>
|
||||
|
||||
{/* Banner / hero */}
|
||||
{ext.bannerUrl && (
|
||||
<div className="mb-6 overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<img
|
||||
src={ext.bannerUrl}
|
||||
alt=""
|
||||
className="block w-full max-h-64 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<div className="flex items-start gap-4 flex-1 min-w-0">
|
||||
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{ext.iconUrl ? (
|
||||
<img
|
||||
src={ext.iconUrl}
|
||||
alt=""
|
||||
className="w-14 h-14 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : isPlugin ? (
|
||||
<Puzzle className="w-7 h-7 text-muted-foreground" />
|
||||
) : (
|
||||
<SwatchBook className="w-7 h-7 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
|
||||
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
||||
{data.installed && !updateAvailable && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium"
|
||||
title={data.installedVersion ? `Installed: v${data.installedVersion}` : undefined}
|
||||
>
|
||||
<Check className="w-3 h-3" /> Installed
|
||||
</span>
|
||||
)}
|
||||
{data.installed && updateAvailable && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400 font-medium"
|
||||
title={`Installed v${data.installedVersion} → v${ext.latestVersion} available`}
|
||||
>
|
||||
<ArrowUpCircle className="w-3 h-3" /> Update available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
isPlugin
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
|
||||
}`}>
|
||||
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
|
||||
</span>
|
||||
{ext.author && (
|
||||
<span>by {ext.author.displayName}</span>
|
||||
)}
|
||||
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
|
||||
{ext.license && <span>{ext.license}</span>}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Download className="w-3 h-3" />
|
||||
{ext.totalDownloads.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
{data.installed ? (
|
||||
<>
|
||||
{updateAvailable && (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={installing || !!bundle.error}
|
||||
title={`Update from v${data.installedVersion} to v${ext.latestVersion}`}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpCircle className="w-4 h-4" />}
|
||||
Update to v{ext.latestVersion}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
Manage
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleUninstall}
|
||||
disabled={uninstalling}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{uninstalling ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
Uninstall
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={installing || !!bundle.error || versionMismatch}
|
||||
title={versionMismatch
|
||||
? `Requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}. Update Bulwark to install.`
|
||||
: undefined}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{versionMismatch && (
|
||||
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Update Bulwark to install this extension</p>
|
||||
<p className="text-xs mt-0.5 opacity-90">
|
||||
Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bundle.error && (
|
||||
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Could not preview bundle</p>
|
||||
<p className="text-xs mt-0.5 opacity-90">{bundle.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">About</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">{ext.description}</p>
|
||||
{ext.longDescription && ext.longDescription !== ext.description && (
|
||||
<p className="text-sm text-muted-foreground mt-3 whitespace-pre-wrap">{ext.longDescription}</p>
|
||||
)}
|
||||
{ext.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{ext.tags.map(tag => (
|
||||
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-4 pt-3 border-t border-border flex-wrap">
|
||||
{ext.minAppVersion && <span>Requires app v{ext.minAppVersion}+</span>}
|
||||
{bundle.size > 0 && <span>Bundle: {(bundle.size / 1024).toFixed(1)} KB</span>}
|
||||
{ext.githubRepo && (
|
||||
<a
|
||||
href={`https://github.com/${ext.githubRepo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{ext.githubRepo}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Screenshots */}
|
||||
{ext.screenshots.length > 0 && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">Screenshots</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||
{ext.screenshots.map((s, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={s.url}
|
||||
alt={s.altText || `Screenshot ${i + 1}`}
|
||||
className="w-full rounded-md border border-border bg-muted"
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Theme color preview */}
|
||||
{!isPlugin && ext.themePreviews.length > 0 && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">Theme preview</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||
{ext.themePreviews.map(preview => (
|
||||
<ThemeColorSwatch key={preview.variant} preview={preview} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Permissions */}
|
||||
{isPlugin && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Permissions</h2>
|
||||
</div>
|
||||
{manifestPerms.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground mt-2">This plugin requests no permissions.</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{manifestPerms.map(perm => {
|
||||
const risky = RISKY_PERMISSIONS.has(perm);
|
||||
return (
|
||||
<li
|
||||
key={perm}
|
||||
className={`flex items-center gap-2 text-sm rounded-md px-2 py-1 ${
|
||||
risky
|
||||
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300'
|
||||
: 'bg-muted/50 text-foreground'
|
||||
}`}
|
||||
>
|
||||
{risky && <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
|
||||
<code className="font-mono text-xs">{perm}</code>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{frameOrigins.length > 0 && (
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<h3 className="text-xs font-medium text-foreground">Iframe origins</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
The plugin will be allowed to embed content from these origins.
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{frameOrigins.map(origin => (
|
||||
<li key={origin} className="text-xs font-mono text-foreground bg-muted/50 px-2 py-1 rounded">
|
||||
{origin}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Settings schema preview */}
|
||||
{isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">User settings</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Settings users will be able to configure after install.</p>
|
||||
<ul className="mt-3 divide-y divide-border">
|
||||
{Object.entries(settingsSchema).map(([key, field]) => (
|
||||
<li key={key} className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-foreground">{key}</code>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{field.type}</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground mt-0.5">{field.label}</div>
|
||||
{field.description && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{field.description}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Source / manifest disclosure */}
|
||||
{bundle.manifest && (
|
||||
<section className="border border-border rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowManifest(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">manifest.json</h2>
|
||||
</div>
|
||||
{showManifest ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{showManifest && (
|
||||
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre">
|
||||
{JSON.stringify(bundle.manifest, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{bundle.source && (
|
||||
<section className="border border-border rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowSource(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">{bundle.source.name}</h2>
|
||||
{bundle.source.truncated && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">truncated</span>
|
||||
)}
|
||||
</div>
|
||||
{showSource ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{showSource && (
|
||||
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre max-h-[600px] overflow-y-auto">
|
||||
{bundle.source.content}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Version history */}
|
||||
{ext.versions.length > 0 && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">Version history</h2>
|
||||
<ul className="mt-3 divide-y divide-border">
|
||||
{ext.versions.slice(0, 5).map(v => (
|
||||
<li key={v.version} className="py-2 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-foreground">v{v.version}</code>
|
||||
{v.publishedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(v.publishedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{v.changelog && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 whitespace-pre-wrap">{v.changelog}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{(v.bundleSize / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record<string, string> | null } }) {
|
||||
const colors = preview.colors || {};
|
||||
const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff');
|
||||
const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a');
|
||||
const accent = colors.primary || colors.accent || '#7c5cff';
|
||||
const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5');
|
||||
const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5');
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-b border-border capitalize">
|
||||
{preview.variant}
|
||||
</div>
|
||||
<div className="p-3 space-y-2" style={{ background: bg, color: fg }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-6 h-6 rounded" style={{ background: accent }} />
|
||||
<span className="text-sm font-medium" style={{ color: fg }}>Sample text</span>
|
||||
</div>
|
||||
<div className="rounded p-2 text-xs" style={{ background: muted, border: `1px solid ${border}` }}>
|
||||
<span style={{ color: fg }}>Card surface</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(colors).slice(0, 6).map(([key, value]) => (
|
||||
<span
|
||||
key={key}
|
||||
title={`${key}: ${value}`}
|
||||
className="inline-block w-4 h-4 rounded border"
|
||||
style={{ background: value, borderColor: border }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=marketplace');
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAdminTabStore, isAdminTab } from '@/stores/admin-tab-store';
|
||||
import { DashboardTab } from './_tabs/dashboard';
|
||||
import { SettingsTab } from './_tabs/settings';
|
||||
import { BrandingTab } from './_tabs/branding';
|
||||
import { AuthTab } from './_tabs/auth';
|
||||
import { PolicyTab } from './_tabs/policy';
|
||||
import { PluginsTab } from './_tabs/plugins';
|
||||
import { ThemesTab } from './_tabs/themes';
|
||||
import { MarketplaceTab } from './_tabs/marketplace';
|
||||
import { VersionTab } from './_tabs/version';
|
||||
import { TelemetryTab } from './_tabs/telemetry';
|
||||
import { LogsTab } from './_tabs/logs';
|
||||
|
||||
export default function AdminPage() {
|
||||
const activeTab = useAdminTabStore((s) => s.activeTab);
|
||||
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
|
||||
|
||||
// Honour deep links from the old route structure: /admin?tab=settings
|
||||
// (emitted by the redirect pages in /admin/<x>/page.tsx) sets the store
|
||||
// once on mount, then strips the param so the URL stays at /admin and
|
||||
// subsequent tab clicks don't accumulate query strings.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const url = new URL(window.location.href);
|
||||
const fromUrl = url.searchParams.get('tab');
|
||||
if (isAdminTab(fromUrl)) {
|
||||
setActiveTab(fromUrl);
|
||||
url.searchParams.delete('tab');
|
||||
window.history.replaceState(null, '', url.pathname + url.search + url.hash);
|
||||
}
|
||||
}, [setActiveTab]);
|
||||
|
||||
switch (activeTab) {
|
||||
case 'dashboard': return <DashboardTab />;
|
||||
case 'settings': return <SettingsTab />;
|
||||
case 'branding': return <BrandingTab />;
|
||||
case 'auth': return <AuthTab />;
|
||||
case 'policy': return <PolicyTab />;
|
||||
case 'plugins': return <PluginsTab />;
|
||||
case 'themes': return <ThemesTab />;
|
||||
case 'marketplace': return <MarketplaceTab />;
|
||||
case 'version': return <VersionTab />;
|
||||
case 'telemetry': return <TelemetryTab />;
|
||||
case 'logs': return <LogsTab />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
// Inline panel handles plugin config now - see _tabs/plugin-config-panel.tsx.
|
||||
// Old deep links land on the plugins tab; the user clicks the gear again.
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=plugins');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=plugins');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=policy');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=settings');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=telemetry');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=themes');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=version');
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import "./globals.css";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import "../globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -16,8 +17,15 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const faviconUrl = process.env.FAVICON_URL;
|
||||
await configManager.ensureLoaded();
|
||||
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||
|
||||
return {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
@@ -30,7 +38,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
||||
icons: { icon: faviconUrl },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,34 +2,43 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
|
||||
export default function NotFound() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
// Don't redirect admin routes to the webmail login page
|
||||
const isAdminRoute = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/');
|
||||
const prefix = getPathPrefix();
|
||||
// Don't redirect admin routes to the webmail login page. Admin paths
|
||||
// are mounted relative to the deployment prefix, so account for it.
|
||||
const adminBase = `${prefix}/admin`;
|
||||
const isAdminRoute = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
|
||||
if (!isAdminRoute) {
|
||||
window.location.href = "/login";
|
||||
window.location.href = `${prefix}/login`;
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Allow admin routes to render the 404 without redirecting
|
||||
const isAdmin = typeof window !== 'undefined' &&
|
||||
(window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/'));
|
||||
let isAdmin = false;
|
||||
if (typeof window !== 'undefined') {
|
||||
const prefix = getPathPrefix();
|
||||
const adminBase = `${prefix}/admin`;
|
||||
isAdmin = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
|
||||
}
|
||||
if (!isAdmin) return null;
|
||||
}
|
||||
|
||||
const prefix = typeof window !== 'undefined' ? getPathPrefix() : '';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center max-w-md px-4">
|
||||
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
||||
<p className="text-muted-foreground mb-6">This page could not be found.</p>
|
||||
<a
|
||||
href="/"
|
||||
href={`${prefix}/`}
|
||||
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Go home
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-client";
|
||||
|
||||
export default async function MailtoProtocolPage() {
|
||||
const t = await getTranslations("protocol_handlers");
|
||||
|
||||
return <MailtoProtocolClient openingText={t("opening_mailto")} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { WebcalProtocolClient } from "@/components/protocol/webcal-protocol-client";
|
||||
|
||||
export default async function WebcalProtocolPage() {
|
||||
const t = await getTranslations("protocol_handlers");
|
||||
|
||||
return <WebcalProtocolClient openingText={t("opening_webcal")} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function SetupLayout({ children }: { children: ReactNode }) {
|
||||
return <div className="min-h-screen bg-background text-foreground">{children}</div>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import '../globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Plugin sandbox',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
style={{ margin: 0, padding: 0, background: 'transparent' }}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||
|
||||
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||
// Next's injected hydration/chunk scripts. With force-static, those scripts
|
||||
// render without a nonce and the strict sandbox CSP blocks them.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function PluginSandboxPage() {
|
||||
return <SandboxRuntime />;
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
Settings as SettingsIcon,
|
||||
Palette,
|
||||
Mail,
|
||||
User,
|
||||
Shield,
|
||||
UserPen,
|
||||
PalmtreeIcon,
|
||||
Calendar,
|
||||
Filter,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Tags,
|
||||
HardDrive,
|
||||
Wrench,
|
||||
BookUser,
|
||||
KeyRound,
|
||||
PanelLeftClose,
|
||||
Bell,
|
||||
Puzzle,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||
import { EmailSettings } from '@/components/settings/email-settings';
|
||||
import { AccountSettings } from '@/components/settings/account-settings';
|
||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||
import { CalendarSettings } from '@/components/settings/calendar-settings';
|
||||
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
|
||||
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
|
||||
import { FilterSettings } from '@/components/settings/filter-settings';
|
||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||
import { FolderSettings } from '@/components/settings/folder-settings';
|
||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||
import { InlineAppView } from '@/components/layout/inline-app-view';
|
||||
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
||||
import { ResizeHandle } from '@/components/layout/resize-handle';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'themes' | 'plugins' | 'advanced';
|
||||
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
||||
|
||||
interface TabDef {
|
||||
id: Tab;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
group: TabGroup;
|
||||
experimental?: boolean;
|
||||
}
|
||||
|
||||
const tabIcons: Record<Tab, LucideIcon> = {
|
||||
appearance: Palette,
|
||||
email: Mail,
|
||||
notifications: Bell,
|
||||
account: User,
|
||||
security: Shield,
|
||||
identities: UserPen,
|
||||
encryption: KeyRound,
|
||||
vacation: PalmtreeIcon,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
filters: Filter,
|
||||
templates: FileText,
|
||||
folders: FolderOpen,
|
||||
keywords: Tags,
|
||||
files: HardDrive,
|
||||
sidebar_apps: PanelLeftClose,
|
||||
themes: Palette,
|
||||
plugins: Puzzle,
|
||||
advanced: Wrench,
|
||||
};
|
||||
|
||||
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations('settings');
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const { stalwartFeaturesEnabled } = useConfig();
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('settings-active-tab');
|
||||
if (saved) return saved as Tab;
|
||||
} catch { /* ignore */ }
|
||||
return 'appearance';
|
||||
});
|
||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
// Sidebar resize state
|
||||
const [settingsSidebarWidth, setSettingsSidebarWidth] = useState(() => {
|
||||
try { const v = localStorage.getItem('settings-sidebar-width'); return v ? Number(v) : 256; } catch { return 256; }
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const dragStartWidth = useRef(256);
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
}, [checkAuth]);
|
||||
|
||||
// Listen for tab change events from child components
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const tab = (e as CustomEvent).detail as Tab;
|
||||
if (tab) {
|
||||
setActiveTab(tab);
|
||||
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
window.addEventListener('settings-tab-change', handler);
|
||||
return () => window.removeEventListener('settings-tab-change', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const supportsVacation = client?.supportsVacationResponse() ?? false;
|
||||
const supportsCalendar = client?.supportsCalendars() ?? false;
|
||||
const supportsSieve = client?.supportsSieve() ?? false;
|
||||
const supportsFiles = client?.supportsFiles() ?? false;
|
||||
|
||||
const tabs: TabDef[] = [
|
||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
|
||||
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
|
||||
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
|
||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
|
||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
|
||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []),
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []),
|
||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
|
||||
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []),
|
||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
|
||||
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup, experimental: true }] : []),
|
||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup, experimental: true }] : []),
|
||||
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
|
||||
];
|
||||
|
||||
// Group tabs by category
|
||||
const groupedTabs = tabGroupOrder
|
||||
.map((group) => ({
|
||||
group,
|
||||
label: t(`tab_groups.${group}`),
|
||||
items: tabs.filter((tab) => tab.group === group),
|
||||
}))
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
const handleTabSelect = (tabId: Tab) => {
|
||||
setActiveTab(tabId);
|
||||
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
||||
if (!isDesktop) {
|
||||
setMobileShowContent(true);
|
||||
}
|
||||
};
|
||||
|
||||
const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label ?? '';
|
||||
|
||||
const renderTabContent = () => (
|
||||
<>
|
||||
{activeTab === 'appearance' && <AppearanceSettings />}
|
||||
{activeTab === 'email' && <EmailSettings />}
|
||||
{activeTab === 'notifications' && <NotificationSettings />}
|
||||
{activeTab === 'account' && <AccountSettings />}
|
||||
{activeTab === 'security' && <AccountSecuritySettings />}
|
||||
{activeTab === 'identities' && <IdentitySettings />}
|
||||
{activeTab === 'encryption' && <SmimeSettings />}
|
||||
{activeTab === 'vacation' && <VacationSettings />}
|
||||
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{activeTab === 'filters' && <FilterSettings />}
|
||||
{activeTab === 'templates' && <TemplateSettings />}
|
||||
{activeTab === 'folders' && <FolderSettings />}
|
||||
{activeTab === 'keywords' && <KeywordSettings />}
|
||||
{activeTab === 'files' && <FilesSettingsComponent />}
|
||||
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
{activeTab === 'themes' && <ThemesSettings />}
|
||||
{activeTab === 'plugins' && <PluginsSettings />}
|
||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||
</>
|
||||
);
|
||||
|
||||
// Mobile layout
|
||||
if (!isDesktop) {
|
||||
// Mobile: show content view
|
||||
if (mobileShowContent) {
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background">
|
||||
{/* Mobile content header */}
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setMobileShowContent(false)}
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile: show tab list
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background">
|
||||
{/* Mobile header */}
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push('/')}
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsIcon className="w-5 h-5 text-muted-foreground" />
|
||||
<h1 className="font-semibold text-lg">{t('title')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="py-2">
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
<div key={group.group}>
|
||||
{groupIndex > 0 && <div className="mx-5 my-2 border-t border-border" />}
|
||||
<div className="px-5 pt-3 pb-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.items.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabSelect(tab.id)}
|
||||
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
{tab.label}
|
||||
{tab.experimental && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning">
|
||||
Experimental
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>{tSidebar('sign_out')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout
|
||||
return (
|
||||
<div className="flex h-dvh bg-background">
|
||||
{/* Navigation Rail */}
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={logout}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||
)}
|
||||
{!inlineApp && (
|
||||
<>
|
||||
{/* Settings Sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary flex flex-col",
|
||||
!isResizing && "transition-[width] duration-300"
|
||||
)}
|
||||
style={{ width: `${settingsSidebarWidth}px` }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t('back_to_mail')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||
<div className="px-2 space-y-0.5">
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
<div key={group.group}>
|
||||
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.items.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
activeTab === tab.id
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
{tab.label}
|
||||
{tab.experimental && (
|
||||
<span className="ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning shrink-0">
|
||||
Experimental
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar resize handle */}
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsResizing(false);
|
||||
localStorage.setItem('settings-sidebar-width', String(settingsSidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
|
||||
/>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto p-8">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Tab Content */}
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
}
|
||||
|
||||
export default function AdminAuthPage() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => { fetchConfig(); }, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: unknown) {
|
||||
setEdits(prev => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): unknown {
|
||||
if (key in edits) return edits[key];
|
||||
return config[key]?.value;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Authentication settings saved.' });
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
await fetchConfig();
|
||||
}
|
||||
}
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth */}
|
||||
<Section title="OAuth / OpenID Connect">
|
||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" />
|
||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||
</Section>
|
||||
|
||||
{/* SSO */}
|
||||
<Section title="Single Sign-On">
|
||||
<Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</Section>
|
||||
|
||||
{/* Session & Security */}
|
||||
<Section title="Session & Security">
|
||||
<Select label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="Allowed Frame Ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." />
|
||||
<Text label="Parent Origin" description="For embedded mode communication" configKey="parentOrigin" value={currentValue('parentOrigin') as string} source={config.parentOrigin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source?: string }) {
|
||||
if (!source || source === 'default') return null;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Text({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text' }: {
|
||||
label: string; description?: string; configKey: string; value: string; source?: string;
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
|
||||
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ label, description, configKey, value, source, onChange, onRevert }: {
|
||||
label: string; description?: string; configKey: string; value: boolean; source?: string;
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => onChange(configKey, !value)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Select({ label, configKey, value, source, options, onChange, onRevert }: {
|
||||
label: string; configKey: string; value: string; source?: string; options: string[];
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Palette,
|
||||
Shield,
|
||||
Scale,
|
||||
ScrollText,
|
||||
LogOut,
|
||||
KeyRound,
|
||||
Puzzle,
|
||||
SwatchBook,
|
||||
Mail,
|
||||
Calendar,
|
||||
BookUser,
|
||||
HardDrive,
|
||||
ArrowLeft,
|
||||
Store,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const NAV_GROUPS = [
|
||||
{
|
||||
label: 'Overview',
|
||||
items: [
|
||||
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Configuration',
|
||||
items: [
|
||||
{ href: '/admin/settings', label: 'Settings', icon: Settings },
|
||||
{ href: '/admin/branding', label: 'Branding', icon: Palette },
|
||||
{ href: '/admin/auth', label: 'Authentication', icon: Shield },
|
||||
{ href: '/admin/policy', label: 'Policy', icon: Scale },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Extensions',
|
||||
items: [
|
||||
{ href: '/admin/plugins', label: 'Plugins', icon: Puzzle },
|
||||
{ href: '/admin/themes', label: 'Themes', icon: SwatchBook },
|
||||
{ href: '/admin/marketplace', label: 'Marketplace', icon: Store },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname !== '/admin/login') {
|
||||
checkAuth();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
|
||||
function getJmapHeaders(): Record<string, string> {
|
||||
return getActiveAccountSlotHeaders();
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const jmapHeaders = getJmapHeaders();
|
||||
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
||||
const data = await res.json();
|
||||
|
||||
const stalwartAdmin = data.stalwartAdmin === true;
|
||||
setIsStalwartAdmin(stalwartAdmin);
|
||||
|
||||
// If neither password-based admin nor Stalwart admin, redirect away
|
||||
if (!data.enabled && !stalwartAdmin) {
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.authenticated) {
|
||||
setAuthenticated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If Stalwart admin but not yet authenticated, auto-login
|
||||
if (stalwartAdmin) {
|
||||
const loginRes = await apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||
body: JSON.stringify({ stalwartAuth: true }),
|
||||
});
|
||||
if (loginRes.ok) {
|
||||
setAuthenticated(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
router.replace('/admin/login');
|
||||
} catch {
|
||||
router.replace('/admin/login');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
||||
router.replace('/admin/login');
|
||||
}
|
||||
|
||||
// Don't gate the login page
|
||||
if (pathname === '/admin/login') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (authenticated === null) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
{/* Slim webmail nav rail */}
|
||||
<nav className="w-14 bg-secondary flex flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||
) : (
|
||||
<div className="w-7 h-7 mb-2" />
|
||||
)}
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Mail"
|
||||
>
|
||||
<Mail className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href="/calendar"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Calendar"
|
||||
>
|
||||
<Calendar className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href="/contacts"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href="/files"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<div className="mt-auto flex flex-col items-center gap-2">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
|
||||
<Shield className="w-[18px] h-[18px]" />
|
||||
</div>
|
||||
<a
|
||||
href="/settings"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Admin Sidebar */}
|
||||
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen">
|
||||
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<div className="px-2 space-y-0.5">
|
||||
{NAV_GROUPS.map((group, groupIndex) => (
|
||||
<div key={group.label}>
|
||||
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.items.map(({ href, label, icon: Icon }) => {
|
||||
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
active ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
|
||||
{!isStalwartAdmin && (
|
||||
<Link
|
||||
href="/admin/change-password"
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
pathname === '/admin/change-password'
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<KeyRound className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
Change Password
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Parse Stalwart error response to extract meaningful error message
|
||||
*/
|
||||
function parseStalwartError(responseText: string): string {
|
||||
try {
|
||||
const error = JSON.parse(responseText);
|
||||
if (error.detail) return error.detail;
|
||||
if (error.error) return error.error;
|
||||
return `HTTP ${error.status || 'Error'}`;
|
||||
} catch {
|
||||
return responseText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/auth
|
||||
* Proxy to Stalwart GET /api/account/auth
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const detail = parseStalwartError(text);
|
||||
logger.warn('Stalwart auth info failed', { status: response.status, detail });
|
||||
return NextResponse.json(
|
||||
{ error: detail || 'Failed to fetch auth info' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart auth proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/auth
|
||||
* Proxy to Stalwart POST /api/account/auth
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Stalwart auth update failed', { status: response.status });
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart auth update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Parse Stalwart error response to extract meaningful error message
|
||||
*/
|
||||
function parseStalwartError(responseText: string): string {
|
||||
try {
|
||||
const error = JSON.parse(responseText);
|
||||
if (error.detail) return error.detail;
|
||||
if (error.error) return error.error;
|
||||
return `HTTP ${error.status || 'Error'}`;
|
||||
} catch {
|
||||
return responseText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/crypto
|
||||
* Proxy to Stalwart GET /api/account/crypto
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const detail = parseStalwartError(text);
|
||||
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
|
||||
return NextResponse.json(
|
||||
{ error: detail || 'Failed to fetch crypto info' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart crypto proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/crypto
|
||||
* Proxy to Stalwart POST /api/account/crypto
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Stalwart crypto update failed', { status: response.status });
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart crypto update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/jmap
|
||||
*
|
||||
* Passthrough to Stalwart's JMAP endpoint using the stored basic-auth
|
||||
* context so the browser does not need access to the user's credentials.
|
||||
*
|
||||
* Body: standard JMAP request `{ using: string[], methodCalls: [...] }`
|
||||
*
|
||||
* In Stalwart 0.16 all management operations (password change, app
|
||||
* passwords, API keys, account settings, etc.) are exposed as JMAP
|
||||
* methods under the `x:` namespace on the same endpoint.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
|
||||
const response = await fetch(`${creds.serverUrl}/jmap/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
return new NextResponse(responseText, {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Stalwart JMAP passthrough error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/password
|
||||
* Change user password via Stalwart PATCH /api/principal/{name}
|
||||
*
|
||||
* Body: { currentPassword: string, newPassword: string }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = await request.json();
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify current password by attempting to authenticate
|
||||
const verifyAuth = `Basic ${Buffer.from(`${creds.username}:${currentPassword}`).toString('base64')}`;
|
||||
const verifyResponse = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': verifyAuth },
|
||||
});
|
||||
|
||||
if (!verifyResponse.ok) {
|
||||
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Change password via Stalwart principal API
|
||||
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify([
|
||||
{ action: 'set', field: 'secrets', value: newPassword },
|
||||
]),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.warn('Stalwart password change failed', { status: response.status });
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to change password', details: text },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
// If session cookie exists, update it with the new password
|
||||
const cookieStore = await cookies();
|
||||
|
||||
if (creds.hasSessionCookie) {
|
||||
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
||||
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
|
||||
}
|
||||
|
||||
if (creds.authHeader.startsWith('Basic ')) {
|
||||
setStalwartAuthContextInStore(cookieStore, creds.slot, {
|
||||
serverUrl: creds.serverUrl,
|
||||
username: creds.username,
|
||||
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Stalwart password change proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Parse Stalwart error response to extract meaningful error message
|
||||
*/
|
||||
function parseStalwartError(responseText: string): string {
|
||||
try {
|
||||
const error = JSON.parse(responseText);
|
||||
if (error.detail) return error.detail;
|
||||
if (error.error) return error.error;
|
||||
return `HTTP ${error.status || 'Error'}`;
|
||||
} catch {
|
||||
return responseText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/principal
|
||||
* Proxy to Stalwart GET /api/principal/{username}
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const detail = parseStalwartError(text);
|
||||
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
|
||||
return NextResponse.json(
|
||||
{ error: detail || 'Failed to fetch principal' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart principal proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/account/stalwart/principal
|
||||
* Proxy to Stalwart PATCH /api/principal/{username}
|
||||
* Body: PrincipalUpdateAction[] (array of {action, field, value})
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Prevent secrets field from being changed through this endpoint (use /password instead)
|
||||
if (Array.isArray(body)) {
|
||||
const hasSecrets = body.some((action: { field?: string }) => action.field === 'secrets');
|
||||
if (hasSecrets) {
|
||||
return NextResponse.json({ error: 'Use /api/account/stalwart/password to change passwords' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Stalwart principal update failed', { status: response.status });
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart principal update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/probe
|
||||
* Detect whether the JMAP server is Stalwart by probing /api/account/auth
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ isStalwart: false });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ isStalwart: false });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean';
|
||||
|
||||
return NextResponse.json({ isStalwart });
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
return NextResponse.json({ isStalwart: false });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ isStalwart: false });
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { logger } from '@/lib/logger';
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
|
||||
|
||||
Binary file not shown.
@@ -1,8 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
|
||||
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
||||
function getBrandingDir(): string {
|
||||
return path.join(getConfigDir(), 'branding');
|
||||
}
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.svg': 'image/svg+xml',
|
||||
@@ -38,11 +41,11 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
|
||||
}
|
||||
|
||||
const filePath = path.join(BRANDING_DIR, safe);
|
||||
const filePath = path.join(getBrandingDir(), safe);
|
||||
|
||||
// Ensure resolved path is still within BRANDING_DIR
|
||||
// Ensure resolved path is still within getBrandingDir()
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
|
||||
if (!resolved.startsWith(path.resolve(getBrandingDir()))) {
|
||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -53,11 +56,19 @@ export async function GET(
|
||||
|
||||
const buffer = await readFile(resolved);
|
||||
|
||||
// SVG can carry inline <script> and event handlers that execute when the
|
||||
// file is fetched as a top-level document. Defense in depth on top of
|
||||
// admin-only upload: nosniff blocks MIME confusion, the CSP forces a
|
||||
// sandboxed unique origin so any script in an SVG is inert and cannot
|
||||
// touch app cookies or storage.
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||
'Content-Length': String(buffer.length),
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Security-Policy':
|
||||
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
||||
function getBrandingDir(): string {
|
||||
return path.join(getConfigDir(), 'branding');
|
||||
}
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/svg+xml',
|
||||
@@ -41,7 +44,7 @@ function sanitizeFilename(name: string): string {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -79,11 +82,11 @@ export async function POST(request: NextRequest) {
|
||||
};
|
||||
const ext = extMap[file.type] || '.png';
|
||||
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||
const filePath = path.join(BRANDING_DIR, safeName);
|
||||
const filePath = path.join(getBrandingDir(), safeName);
|
||||
|
||||
// Ensure branding directory exists
|
||||
if (!existsSync(BRANDING_DIR)) {
|
||||
await mkdir(BRANDING_DIR, { recursive: true });
|
||||
if (!existsSync(getBrandingDir())) {
|
||||
await mkdir(getBrandingDir(), { recursive: true });
|
||||
}
|
||||
|
||||
// Write file to disk
|
||||
@@ -111,7 +114,7 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -125,7 +128,7 @@ export async function DELETE(request: NextRequest) {
|
||||
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||
let removed = false;
|
||||
for (const ext of possibleExts) {
|
||||
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
|
||||
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
removed = true;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { logger } from '@/lib/logger';
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -2,21 +2,45 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// Strings that count as "no real secret configured" - used so the dashboard
|
||||
// can warn about a placeholder session secret without us ever returning the
|
||||
// raw value to the client.
|
||||
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
|
||||
|
||||
/**
|
||||
* GET /api/admin/config - Get full config with sources (admin-protected)
|
||||
*
|
||||
* Sensitive keys (sessionSecret, oauthClientSecret) are returned with
|
||||
* `value` omitted and a `hasValue` boolean instead. An admin session is
|
||||
* enough to read every other config knob; the secrets themselves stay on
|
||||
* the server so that an XSS or session-theft can't lift them in one
|
||||
* request and forge admin/user session cookies offline.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const config = configManager.getAllWithSources();
|
||||
|
||||
return NextResponse.json(config, {
|
||||
const safe: Record<string, { value?: unknown; source: 'admin' | 'env' | 'default'; hasValue?: boolean }> = {};
|
||||
for (const [key, entry] of Object.entries(config)) {
|
||||
if (SENSITIVE_CONFIG_KEYS.has(key)) {
|
||||
const v = entry.value;
|
||||
const hasValue =
|
||||
typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v);
|
||||
safe[key] = { source: entry.source, hasValue };
|
||||
} else {
|
||||
safe[key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(safe, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -30,7 +54,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -47,6 +71,23 @@ export async function PATCH(request: NextRequest) {
|
||||
return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Normalize jmapServers: pass through the parser so invalid entries are
|
||||
// rejected (bad ids, duplicate ids, non-HTTP URLs) before they're persisted.
|
||||
if ('jmapServers' in updates) {
|
||||
const incoming = updates.jmapServers;
|
||||
if (incoming != null && !Array.isArray(incoming)) {
|
||||
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
|
||||
}
|
||||
const sanitized = parseJmapServers(incoming);
|
||||
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
|
||||
if (sanitized.length !== incomingCount) {
|
||||
return NextResponse.json({
|
||||
error: 'One or more jmapServers entries are invalid (each needs a unique id, label, and HTTP(S) url).',
|
||||
}, { status: 400 });
|
||||
}
|
||||
updates.jmapServers = sanitized;
|
||||
}
|
||||
|
||||
// Get old values for audit
|
||||
const oldValues: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(updates)) {
|
||||
@@ -68,7 +109,7 @@ export async function PATCH(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
getPluginRegistry,
|
||||
getThemeRegistry,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
async function getDirectoryUrl(): Promise<string> {
|
||||
await configManager.ensureLoaded();
|
||||
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||
}
|
||||
|
||||
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||
|
||||
/**
|
||||
* GET /api/admin/marketplace/[slug]
|
||||
* Returns full preview info for an extension: directory metadata,
|
||||
* the bundle's manifest, a (truncated) source preview, and install status.
|
||||
* Lets admins audit what they're about to install before pressing the button.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { slug } = await params;
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
|
||||
// 1. Extension metadata + screenshots + theme previews from the directory
|
||||
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, directoryUrl);
|
||||
const detailRes = await fetch(detailUrl.toString(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!detailRes.ok) {
|
||||
const status = detailRes.status === 404 ? 404 : 502;
|
||||
return NextResponse.json(
|
||||
{ error: status === 404 ? 'Extension not found' : 'Directory request failed' },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
const detailJson = await detailRes.json();
|
||||
const extension = detailJson.data as Record<string, unknown> | undefined;
|
||||
if (!extension) {
|
||||
return NextResponse.json({ error: 'Extension not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const type = extension.type as 'plugin' | 'theme';
|
||||
const latestVersion = (extension.latestVersion as { version?: string } | null)?.version
|
||||
?? null;
|
||||
|
||||
// 2. Pull the bundle so we can show what's actually inside.
|
||||
let manifest: Record<string, unknown> | null = null;
|
||||
let sourcePreview: { name: string; content: string; truncated: boolean } | null = null;
|
||||
let bundleError: string | null = null;
|
||||
let bundleSize = 0;
|
||||
|
||||
if (latestVersion) {
|
||||
try {
|
||||
const bundleUrl = new URL(
|
||||
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
||||
directoryUrl,
|
||||
);
|
||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!bundleRes.ok) {
|
||||
bundleError = `Bundle download failed (${bundleRes.status})`;
|
||||
} else {
|
||||
const buffer = await bundleRes.arrayBuffer();
|
||||
bundleSize = buffer.byteLength;
|
||||
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
|
||||
if (buffer.byteLength > maxSize) {
|
||||
bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`;
|
||||
} else {
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
// Detect optional root directory inside the ZIP.
|
||||
const entries = Object.keys(zip.files);
|
||||
const topDirs = new Set(entries.map((e) => e.split('/')[0]));
|
||||
let root = '';
|
||||
if (topDirs.size === 1) {
|
||||
const dir = [...topDirs][0];
|
||||
if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) {
|
||||
root = dir + '/';
|
||||
}
|
||||
}
|
||||
|
||||
const manifestFile = zip.file(root + 'manifest.json');
|
||||
if (!manifestFile) {
|
||||
bundleError = 'Bundle missing manifest.json';
|
||||
} else {
|
||||
try {
|
||||
manifest = JSON.parse(await manifestFile.async('string'));
|
||||
} catch {
|
||||
bundleError = 'Invalid manifest.json in bundle';
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest) {
|
||||
if (type === 'theme') {
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
if (cssFile) {
|
||||
const css = await cssFile.async('string');
|
||||
sourcePreview = {
|
||||
name: 'theme.css',
|
||||
content: css.length > MAX_PREVIEW_SOURCE_LEN
|
||||
? css.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||
: css,
|
||||
truncated: css.length > MAX_PREVIEW_SOURCE_LEN,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const entrypoint = (manifest.entrypoint as string) || 'index.js';
|
||||
const jsFile = zip.file(root + entrypoint);
|
||||
if (jsFile) {
|
||||
const code = await jsFile.async('string');
|
||||
sourcePreview = {
|
||||
name: entrypoint,
|
||||
content: code.length > MAX_PREVIEW_SOURCE_LEN
|
||||
? code.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||
: code,
|
||||
truncated: code.length > MAX_PREVIEW_SOURCE_LEN,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
bundleError = err instanceof Error ? err.message : 'Failed to read bundle';
|
||||
}
|
||||
} else {
|
||||
bundleError = 'Extension has no published version';
|
||||
}
|
||||
|
||||
// 3. Install status (slug is used as the registry id at install time)
|
||||
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||
getPluginRegistry(),
|
||||
getThemeRegistry(),
|
||||
]);
|
||||
const installedEntry = type === 'theme'
|
||||
? themeRegistry.themes.find((t) => t.id === slug)
|
||||
: pluginRegistry.plugins.find((p) => p.id === slug);
|
||||
const installed = installedEntry !== undefined;
|
||||
const installedVersion = installedEntry?.version ?? null;
|
||||
|
||||
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
||||
const screenshots = Array.isArray(extension.screenshots)
|
||||
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
||||
url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(),
|
||||
altText: s.altText ?? null,
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Strip the heavy `manifest` blob from versions when echoing the directory data.
|
||||
const versions = Array.isArray(extension.versions)
|
||||
? (extension.versions as Array<Record<string, unknown>>).map((v) => ({
|
||||
version: v.version,
|
||||
changelog: v.changelog,
|
||||
bundleSize: v.bundleSize,
|
||||
minAppVersion: v.minAppVersion,
|
||||
publishedAt: v.publishedAt,
|
||||
permissions: v.permissions,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const fileUrl = (path: unknown): string | null =>
|
||||
typeof path === 'string' && path
|
||||
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||
: null;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
extension: {
|
||||
slug: extension.slug,
|
||||
name: extension.name,
|
||||
type: extension.type,
|
||||
pluginType: extension.pluginType ?? null,
|
||||
description: extension.description,
|
||||
longDescription: extension.longDescription ?? null,
|
||||
tags: extension.tags ?? [],
|
||||
permissions: extension.permissions ?? [],
|
||||
totalDownloads: extension.totalDownloads ?? 0,
|
||||
featured: extension.featured ?? false,
|
||||
githubRepo: extension.githubRepo ?? null,
|
||||
license: extension.license ?? null,
|
||||
minAppVersion: extension.minAppVersion ?? null,
|
||||
iconUrl: fileUrl(extension.iconPath),
|
||||
bannerUrl: fileUrl(extension.bannerPath),
|
||||
author: extension.author ?? null,
|
||||
latestVersion,
|
||||
versions,
|
||||
screenshots,
|
||||
themePreviews: extension.themePreviews ?? [],
|
||||
createdAt: extension.createdAt ?? null,
|
||||
updatedAt: extension.updatedAt ?? null,
|
||||
},
|
||||
bundle: {
|
||||
manifest,
|
||||
source: sourcePreview,
|
||||
size: bundleSize,
|
||||
error: bundleError,
|
||||
},
|
||||
installed,
|
||||
installedVersion,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Marketplace preview error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load preview' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,28 @@ import { logger } from '@/lib/logger';
|
||||
import {
|
||||
savePlugin,
|
||||
saveTheme,
|
||||
getPlugin,
|
||||
getTheme,
|
||||
getPluginRegistry,
|
||||
getThemeRegistry,
|
||||
type ServerPlugin,
|
||||
type ServerTheme,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
sanitizeApiPostPaths,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
||||
async function getDirectoryUrl(): Promise<string> {
|
||||
await configManager.ensureLoaded();
|
||||
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||
@@ -22,11 +34,12 @@ const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
const { searchParams } = request.nextUrl;
|
||||
const url = new URL('/api/v1/extensions', DIRECTORY_URL);
|
||||
const url = new URL('/api/v1/extensions', directoryUrl);
|
||||
|
||||
// Forward all search params
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
@@ -53,16 +66,32 @@ export async function GET(request: NextRequest) {
|
||||
getThemeRegistry(),
|
||||
]);
|
||||
|
||||
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
|
||||
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
|
||||
const installedPluginVersions = new Map(
|
||||
pluginRegistry.plugins.map(p => [p.id, p.version] as const),
|
||||
);
|
||||
const installedThemeVersions = new Map(
|
||||
themeRegistry.themes.map(t => [t.id, t.version] as const),
|
||||
);
|
||||
|
||||
const fileUrl = (path: unknown): string | null =>
|
||||
typeof path === 'string' && path
|
||||
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||
: null;
|
||||
|
||||
if (data.data) {
|
||||
data.data = data.data.map((ext: Record<string, unknown>) => ({
|
||||
data.data = data.data.map((ext: Record<string, unknown>) => {
|
||||
const slug = ext.slug as string;
|
||||
const installedVersion = ext.type === 'theme'
|
||||
? installedThemeVersions.get(slug) ?? null
|
||||
: installedPluginVersions.get(slug) ?? null;
|
||||
return {
|
||||
...ext,
|
||||
installed: ext.type === 'theme'
|
||||
? installedThemes.has(ext.slug as string)
|
||||
: installedPlugins.has(ext.slug as string),
|
||||
}));
|
||||
iconUrl: fileUrl(ext.iconPath),
|
||||
bannerUrl: fileUrl(ext.bannerPath),
|
||||
installed: installedVersion !== null,
|
||||
installedVersion,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(data, {
|
||||
@@ -80,7 +109,7 @@ export async function GET(request: NextRequest) {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -95,7 +124,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Download the bundle from the directory
|
||||
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL);
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl);
|
||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
@@ -151,6 +181,18 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Resolve and strictly validate the id used as a filename. Marketplace
|
||||
// bundles are authored by a third-party publisher; without this an id
|
||||
// like "../../foo" causes savePlugin/saveTheme to write outside the
|
||||
// plugins/themes dir via path.join.
|
||||
const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug;
|
||||
if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'theme') {
|
||||
// Read theme.css
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
@@ -169,22 +211,43 @@ export async function POST(request: NextRequest) {
|
||||
warnings.push(...sanitized.warnings);
|
||||
}
|
||||
|
||||
const existingTheme = await getTheme(resolvedId);
|
||||
const isUpdate = existingTheme !== null;
|
||||
|
||||
const theme: ServerTheme = {
|
||||
id: (manifest.id as string) || slug,
|
||||
id: resolvedId,
|
||||
name: (manifest.name as string) || slug,
|
||||
version: (manifest.version as string) || version,
|
||||
// Prefer the directory-published version (what we requested) over
|
||||
// manifest.version. Publishers sometimes forget to bump the version
|
||||
// inside the bundle's manifest.json; trusting it would make the
|
||||
// update never appear to "stick" — the registry would keep showing
|
||||
// the older version even after a successful update.
|
||||
version: version || (manifest.version as string),
|
||||
author: (manifest.author as string) || 'Unknown',
|
||||
description: (manifest.description as string) || '',
|
||||
variants: (manifest.variants as string[]) || ['light', 'dark'],
|
||||
enabled: true,
|
||||
installedAt: now,
|
||||
enabled: existingTheme?.enabled ?? true,
|
||||
...(existingTheme?.forceEnabled !== undefined
|
||||
? { forceEnabled: existingTheme.forceEnabled }
|
||||
: {}),
|
||||
installedAt: existingTheme?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await saveTheme(theme, css);
|
||||
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip);
|
||||
await auditLog(
|
||||
isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme',
|
||||
{
|
||||
id: theme.id,
|
||||
name: theme.name,
|
||||
version: theme.version,
|
||||
slug,
|
||||
...(isUpdate ? { previousVersion: existingTheme.version } : {}),
|
||||
},
|
||||
ip,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, theme, warnings });
|
||||
return NextResponse.json({ success: true, theme, warnings, updated: isUpdate });
|
||||
} else {
|
||||
// Plugin installation
|
||||
// Read entrypoint JS
|
||||
@@ -226,24 +289,101 @@ export async function POST(request: NextRequest) {
|
||||
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
||||
}
|
||||
|
||||
// Plugins may declare iframe origins they need for embedded content.
|
||||
// Anything that doesn't pass strict origin validation is silently
|
||||
// dropped - the plugin still installs, but those origins are not
|
||||
// added to the host CSP.
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
|
||||
? (manifest.frameOrigins as unknown[]).filter(
|
||||
(v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
|
||||
)
|
||||
: [];
|
||||
if (droppedFrameOrigins.length > 0) {
|
||||
warnings.push(
|
||||
`Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||
const droppedHttpOrigins = Array.isArray(manifest.httpOrigins)
|
||||
? (manifest.httpOrigins as unknown[]).filter(
|
||||
(v) => typeof v !== 'string' || !declaredHttpOrigins.includes(v),
|
||||
)
|
||||
: [];
|
||||
if (droppedHttpOrigins.length > 0) {
|
||||
warnings.push(
|
||||
`Ignored invalid httpOrigins: ${droppedHttpOrigins.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||
const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths)
|
||||
? (manifest.apiPostPaths as unknown[]).filter(
|
||||
(v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v),
|
||||
)
|
||||
: [];
|
||||
if (droppedApiPostPaths.length > 0) {
|
||||
warnings.push(
|
||||
`Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingPlugin = await getPlugin(resolvedId);
|
||||
const isUpdate = existingPlugin !== null;
|
||||
|
||||
const plugin: ServerPlugin = {
|
||||
id: (manifest.id as string) || slug,
|
||||
id: resolvedId,
|
||||
name: (manifest.name as string) || slug,
|
||||
version: (manifest.version as string) || version,
|
||||
// See theme branch: trust the directory-published version, not
|
||||
// manifest.version, so updates actually stick in the registry.
|
||||
version: version || (manifest.version as string),
|
||||
author: (manifest.author as string) || 'Unknown',
|
||||
description: (manifest.description as string) || '',
|
||||
type: (manifest.type as string) || 'hook',
|
||||
permissions,
|
||||
entrypoint,
|
||||
enabled: true,
|
||||
installedAt: now,
|
||||
enabled: existingPlugin?.enabled ?? true,
|
||||
...(existingPlugin?.forceEnabled !== undefined
|
||||
? { forceEnabled: existingPlugin.forceEnabled }
|
||||
: {}),
|
||||
installedAt: existingPlugin?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||
: {}),
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
...(declaredHttpOrigins.length > 0
|
||||
? { httpOrigins: declaredHttpOrigins }
|
||||
: {}),
|
||||
...(declaredApiPostPaths.length > 0
|
||||
? { apiPostPaths: declaredApiPostPaths }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog(
|
||||
isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin',
|
||||
{
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
slug,
|
||||
frameOrigins: declaredFrameOrigins,
|
||||
httpOrigins: declaredHttpOrigins,
|
||||
apiPostPaths: declaredApiPostPaths,
|
||||
...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
|
||||
},
|
||||
ip,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, plugin, warnings });
|
||||
return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { locales as ALL_LOCALES } from '@/i18n/routing';
|
||||
|
||||
const CLIENT_ID = 'bulwark-webmail';
|
||||
const CLIENT_DESCRIPTION = 'Bulwark Webmail (auto-configured)';
|
||||
const JMAP_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface JmapMethodCall {
|
||||
using: string[];
|
||||
methodCalls: Array<[string, Record<string, unknown>, string]>;
|
||||
}
|
||||
|
||||
interface JmapMethodResponse {
|
||||
methodResponses?: Array<[string, Record<string, unknown>, string]>;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), JMAP_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function jmapCall(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
body: JmapMethodCall,
|
||||
): Promise<JmapMethodResponse> {
|
||||
const res = await fetchWithTimeout(`${serverUrl}/jmap/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`JMAP HTTP ${res.status} ${text.slice(0, 200)}`);
|
||||
}
|
||||
return res.json() as Promise<JmapMethodResponse>;
|
||||
}
|
||||
|
||||
async function getStalwartAccountId(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
): Promise<string | null> {
|
||||
const res = await fetchWithTimeout(`${serverUrl}/.well-known/jmap`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': authHeader },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const session = await res.json() as { primaryAccounts?: Record<string, string> };
|
||||
return session.primaryAccounts?.['urn:stalwart:jmap']
|
||||
?? session.primaryAccounts?.['urn:ietf:params:jmap:mail']
|
||||
?? Object.values(session.primaryAccounts ?? {})[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
function buildRedirectUris(origin: string, localeList: readonly string[]): Record<string, true> {
|
||||
const out: Record<string, true> = {};
|
||||
for (const loc of localeList) {
|
||||
out[`${origin}/${loc}/auth/callback`] = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface SetupRequestBody {
|
||||
origin?: string;
|
||||
issuerUrl?: string;
|
||||
locales?: string[];
|
||||
oauthOnly?: boolean;
|
||||
}
|
||||
|
||||
function isValidOriginUrl(value: string): boolean {
|
||||
return /^https?:\/\/[^/]+$/.test(value);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No Stalwart session available. Sign in to your mail account in another tab and retry.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json() as SetupRequestBody;
|
||||
const origin = (body.origin ?? '').trim().replace(/\/+$/, '');
|
||||
if (!isValidOriginUrl(origin)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Webmail origin must be a URL like "https://webmail.example.com" with no path.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const issuerUrl = (body.issuerUrl ?? origin).trim().replace(/\/+$/, '');
|
||||
if (!isValidOriginUrl(issuerUrl)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stalwart issuer URL must be a URL like "https://mail.example.com" with no path.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const localeList = Array.isArray(body.locales) && body.locales.length > 0
|
||||
? body.locales.filter(l => typeof l === 'string' && /^[a-z]{2,5}(-[A-Za-z0-9]+)*$/.test(l))
|
||||
: Array.from(ALL_LOCALES);
|
||||
if (localeList.length === 0) {
|
||||
return NextResponse.json({ error: 'No valid locales supplied.' }, { status: 400 });
|
||||
}
|
||||
const oauthOnly = body.oauthOnly === true;
|
||||
|
||||
const accountId = await getStalwartAccountId(creds.serverUrl, creds.authHeader);
|
||||
if (!accountId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not resolve Stalwart account from JMAP session.' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const queryRes = await jmapCall(creds.serverUrl, creds.authHeader, {
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
methodCalls: [[
|
||||
'x:OAuthClient/query',
|
||||
{ accountId, filter: { clientId: CLIENT_ID } },
|
||||
'0',
|
||||
]],
|
||||
});
|
||||
|
||||
const queryEntry = queryRes.methodResponses?.[0];
|
||||
if (!queryEntry || queryEntry[0] === 'error') {
|
||||
return NextResponse.json({
|
||||
error: 'Stalwart denied OAuthClient/query - your Stalwart account likely lacks admin permissions.',
|
||||
detail: queryEntry?.[1],
|
||||
}, { status: 403 });
|
||||
}
|
||||
const existingIds = (queryEntry[1].ids as string[] | undefined) ?? [];
|
||||
|
||||
const secret = randomBytes(32).toString('base64url');
|
||||
const redirectUris = buildRedirectUris(origin, localeList);
|
||||
|
||||
let setArgs: Record<string, unknown>;
|
||||
let action: 'created' | 'updated';
|
||||
if (existingIds.length > 0) {
|
||||
const targetId = existingIds[0];
|
||||
action = 'updated';
|
||||
setArgs = {
|
||||
accountId,
|
||||
update: {
|
||||
[targetId]: {
|
||||
secret,
|
||||
redirectUris,
|
||||
description: CLIENT_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
action = 'created';
|
||||
setArgs = {
|
||||
accountId,
|
||||
create: {
|
||||
new: {
|
||||
clientId: CLIENT_ID,
|
||||
description: CLIENT_DESCRIPTION,
|
||||
secret,
|
||||
redirectUris,
|
||||
contacts: { [creds.username]: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const setRes = await jmapCall(creds.serverUrl, creds.authHeader, {
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
methodCalls: [['x:OAuthClient/set', setArgs, '0']],
|
||||
});
|
||||
|
||||
const setEntry = setRes.methodResponses?.[0];
|
||||
if (!setEntry || setEntry[0] === 'error') {
|
||||
return NextResponse.json({
|
||||
error: 'Stalwart denied OAuthClient/set - admin permissions required.',
|
||||
detail: setEntry?.[1],
|
||||
}, { status: 403 });
|
||||
}
|
||||
const setBody = setEntry[1] as {
|
||||
notCreated?: Record<string, unknown>;
|
||||
notUpdated?: Record<string, unknown>;
|
||||
};
|
||||
if (setBody.notCreated && Object.keys(setBody.notCreated).length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stalwart refused to create the OAuth client.', detail: setBody.notCreated },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
if (setBody.notUpdated && Object.keys(setBody.notUpdated).length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stalwart refused to update the OAuth client.', detail: setBody.notUpdated },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const updates: Record<string, unknown> = {
|
||||
oauthEnabled: true,
|
||||
oauthClientId: CLIENT_ID,
|
||||
oauthClientSecret: secret,
|
||||
oauthIssuerUrl: issuerUrl,
|
||||
};
|
||||
if (oauthOnly) updates.oauthOnly = true;
|
||||
await configManager.setAdminConfig(updates);
|
||||
|
||||
await auditLog('admin.oauth_setup', {
|
||||
action,
|
||||
clientId: CLIENT_ID,
|
||||
origin,
|
||||
issuer: issuerUrl,
|
||||
redirectUriCount: localeList.length,
|
||||
oauthOnly,
|
||||
}, ip);
|
||||
|
||||
logger.info('Admin OAuth setup', {
|
||||
action,
|
||||
clientId: CLIENT_ID,
|
||||
origin,
|
||||
issuer: issuerUrl,
|
||||
locales: localeList.length,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action,
|
||||
clientId: CLIENT_ID,
|
||||
origin,
|
||||
issuerUrl,
|
||||
redirectUriCount: localeList.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Admin OAuth setup error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals';
|
||||
|
||||
/**
|
||||
* Admin-protected CRUD for the per-(pluginId, bundleHash) approval table.
|
||||
*
|
||||
* GET /api/admin/plugin-approvals → list all entries
|
||||
* POST /api/admin/plugin-approvals → { pluginId, bundleHash, decision: 'approved'|'denied' }
|
||||
* DELETE /api/admin/plugin-approvals?pluginId=…&bundleHash=… → revoke
|
||||
*/
|
||||
|
||||
function isValidId(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
|
||||
}
|
||||
function isValidHash(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
const entries = await listApprovals();
|
||||
return NextResponse.json({ entries }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approvals GET', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
// AdminSessionPayload carries only role/iat/exp; we use a stable label
|
||||
// for the audit trail rather than a per-user identity.
|
||||
const adminUser = 'admin';
|
||||
void result;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
let body: unknown;
|
||||
try { body = await request.json(); } catch { body = null; }
|
||||
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; decision?: unknown };
|
||||
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
if (b.decision !== 'approved' && b.decision !== 'denied') {
|
||||
return NextResponse.json({ error: 'decision must be "approved" or "denied"' }, { status: 400 });
|
||||
}
|
||||
|
||||
const entry = await decideApproval(b.pluginId, b.bundleHash, b.decision, adminUser);
|
||||
await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip);
|
||||
return NextResponse.json({ entry });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
// AdminSessionPayload carries only role/iat/exp; we use a stable label
|
||||
// for the audit trail rather than a per-user identity.
|
||||
const adminUser = 'admin';
|
||||
void result;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
const pluginId = request.nextUrl.searchParams.get('pluginId');
|
||||
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
|
||||
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
await revokeApproval(pluginId, bundleHash);
|
||||
await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
||||
import { signBytes } from '@/lib/admin/plugin-signing';
|
||||
|
||||
async function safeSign(code: string): Promise<string | null> {
|
||||
try { return await signBytes(code); } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||
@@ -8,7 +14,7 @@ import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||
* Only serves plugins that exist in the registry and are enabled.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
@@ -19,6 +25,22 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Dev plugins are read (and optionally bundled) straight from disk and
|
||||
// served with no caching so every refresh picks up the latest source.
|
||||
const devEntry = await getDevPlugin(id);
|
||||
if (devEntry) {
|
||||
const code = await readDevBundle(devEntry);
|
||||
const signature = await safeSign(code);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||
};
|
||||
if (signature) headers['X-Bundle-Signature'] = signature;
|
||||
return new NextResponse(code, { headers });
|
||||
}
|
||||
|
||||
const plugin = await getPlugin(id);
|
||||
if (!plugin) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
@@ -33,13 +55,25 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Bundle not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(code, {
|
||||
headers: {
|
||||
// Use the registry's bundleHash as the ETag so the browser can revalidate
|
||||
// cheaply. Cache-Control: no-cache forces revalidation on every request,
|
||||
// but a matching If-None-Match returns 304 with no body.
|
||||
const etag = plugin.bundleHash ? `"${plugin.bundleHash}"` : undefined;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||
},
|
||||
});
|
||||
'Cache-Control': 'private, no-cache, must-revalidate',
|
||||
};
|
||||
if (etag) headers['ETag'] = etag;
|
||||
|
||||
const signature = await safeSign(code);
|
||||
if (signature) headers['X-Bundle-Signature'] = signature;
|
||||
|
||||
if (etag && request.headers.get('if-none-match') === etag) {
|
||||
return new NextResponse(null, { status: 304, headers });
|
||||
}
|
||||
|
||||
headers['Content-Length'] = String(Buffer.byteLength(code, 'utf-8'));
|
||||
return new NextResponse(code, { headers });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||
import { getDevPlugin } from '@/lib/admin/plugin-dev';
|
||||
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/** Resolve a plugin from the persisted registry first, then PLUGIN_DEV_DIR. */
|
||||
async function resolvePlugin(id: string) {
|
||||
const registered = await getPlugin(id);
|
||||
if (registered) return registered;
|
||||
const dev = await getDevPlugin(id);
|
||||
return dev?.plugin ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins/[id]/config - Read all config for a plugin
|
||||
* GET /api/admin/plugins/[id]/config - Read plugin config
|
||||
*
|
||||
* Returns the full config object for admin-configured plugin settings.
|
||||
* This endpoint is accessible from the client-side plugin API.
|
||||
* - Admin sessions receive every field, including those declared
|
||||
* `type: 'secret'` in the plugin's configSchema.
|
||||
* - Authenticated mailbox users (the plugin running in their browser)
|
||||
* receive only non-secret fields.
|
||||
* - Anonymous callers are rejected so unauthenticated visitors cannot
|
||||
* enumerate plugin secrets.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
@@ -20,13 +34,39 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const plugin = await getPlugin(id);
|
||||
const adminAuth = await requireAdminAuth(request);
|
||||
const isAdmin = !('error' in adminAuth);
|
||||
|
||||
if (!isAdmin) {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = await resolvePlugin(id);
|
||||
if (!plugin) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const config = await getPluginConfig(id);
|
||||
return NextResponse.json(config, {
|
||||
|
||||
let response: Record<string, unknown>;
|
||||
if (isAdmin) {
|
||||
response = config;
|
||||
} else {
|
||||
response = {};
|
||||
const schema = plugin.configSchema;
|
||||
if (schema) {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
const field = schema[key];
|
||||
if (!field || field.type === 'secret') continue;
|
||||
response[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch {
|
||||
@@ -45,7 +85,7 @@ export async function PUT(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { id } = await params;
|
||||
@@ -54,7 +94,7 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const plugin = await getPlugin(id);
|
||||
const plugin = await resolvePlugin(id);
|
||||
if (!plugin) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
@@ -75,6 +115,13 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (plugin.configSchema && !plugin.configSchema[body.key]) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Key is not declared in the plugin configSchema' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await setPluginConfig(id, body.key, body.value);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
@@ -92,7 +139,7 @@ export async function DELETE(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -8,6 +8,13 @@ import {
|
||||
deletePlugin as removePlugin,
|
||||
type ServerPlugin,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
sanitizeApiPostPaths,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
|
||||
// Server-side extraction using the same validation logic
|
||||
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
||||
@@ -25,13 +32,25 @@ const SUSPICIOUS_JS_PATTERNS = [
|
||||
/**
|
||||
* GET /api/admin/plugins - List all admin-managed plugins
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const registry = await getPluginRegistry();
|
||||
return NextResponse.json(registry.plugins, {
|
||||
const [registry, devEntries] = await Promise.all([
|
||||
getPluginRegistry(),
|
||||
listDevPlugins(),
|
||||
]);
|
||||
|
||||
// Dev plugins win on id collision so admins see what users actually load.
|
||||
const devIds = new Set(devEntries.map(e => e.plugin.id));
|
||||
const merged = [
|
||||
...devEntries.map(e => ({ ...e.plugin, dev: true as const })),
|
||||
...registry.plugins
|
||||
.filter(p => !devIds.has(p.id))
|
||||
.map(p => ({ ...p, dev: false as const })),
|
||||
];
|
||||
return NextResponse.json(merged, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -45,7 +64,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -152,6 +171,10 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const plugin: ServerPlugin = {
|
||||
id: manifest.id as string,
|
||||
@@ -166,12 +189,25 @@ export async function POST(request: NextRequest) {
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||
: {}),
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
...(declaredHttpOrigins.length > 0
|
||||
? { httpOrigins: declaredHttpOrigins }
|
||||
: {}),
|
||||
...(declaredApiPostPaths.length > 0
|
||||
? { apiPostPaths: declaredApiPostPaths }
|
||||
: {}),
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
|
||||
|
||||
return NextResponse.json({ plugin });
|
||||
} catch (error) {
|
||||
@@ -186,7 +222,7 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -204,10 +240,24 @@ export async function PATCH(request: NextRequest) {
|
||||
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||
|
||||
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
||||
const updated = await updatePluginMeta(id, updates);
|
||||
let updated = await updatePluginMeta(id, updates);
|
||||
if (!updated) {
|
||||
// Dev plugins (PLUGIN_DEV_DIR) aren't in the persisted registry, but
|
||||
// forceEnabled is canonical-stored in policy.forceEnabledPlugins on the
|
||||
// client. Skip the registry write and return the live dev plugin so the
|
||||
// policy save path can proceed.
|
||||
const devEntries = await listDevPlugins();
|
||||
const devEntry = devEntries.find(e => e.plugin.id === id);
|
||||
if (!devEntry) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
updated = { ...devEntry.plugin, ...updates };
|
||||
}
|
||||
|
||||
// Enable/disable changes the set of plugins contributing frame origins.
|
||||
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
|
||||
invalidateFrameOriginsCache();
|
||||
}
|
||||
|
||||
await auditLog('plugin.update', { id, ...updates }, ip);
|
||||
return NextResponse.json({ plugin: updated });
|
||||
@@ -223,7 +273,7 @@ export async function PATCH(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -238,6 +288,7 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.delete', { id }, ip);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* GET /api/admin/stalwart-check
|
||||
* Check if the currently logged-in user is a Stalwart admin.
|
||||
* Probes the admin-only principal-list endpoint - if the user can access it, they're an admin.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ isStalwartAdmin: false }, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
|
||||
// Probe an admin-only endpoint: listing principals requires admin privileges.
|
||||
// Use limit=1 to minimize payload.
|
||||
const url = `${creds.apiUrl}/api/principal?limit=1`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
const isStalwartAdmin = response.ok;
|
||||
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
|
||||
|
||||
return NextResponse.json({ isStalwartAdmin }, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Stalwart admin check error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown',
|
||||
});
|
||||
return NextResponse.json({ isStalwartAdmin: false }, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
effectiveConsent,
|
||||
loadState,
|
||||
saveState,
|
||||
buildPayload,
|
||||
sendOnce,
|
||||
reschedule,
|
||||
DEFAULT_ENDPOINT,
|
||||
getLoginCounts,
|
||||
resolveEndpointAllowed,
|
||||
} from '@/lib/telemetry';
|
||||
|
||||
/**
|
||||
* GET /api/admin/telemetry
|
||||
* Returns current consent + endpoint + next/last send + a live preview
|
||||
* of exactly what the next heartbeat would contain.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const { consent, source, state } = await effectiveConsent();
|
||||
const [payload, accountCounts] = await Promise.all([
|
||||
buildPayload(),
|
||||
getLoginCounts(),
|
||||
]);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
consent,
|
||||
consentSource: source,
|
||||
endpoint: state.endpoint || DEFAULT_ENDPOINT,
|
||||
consentedAt: state.consentedAt,
|
||||
lastSentAt: state.lastSentAt,
|
||||
nextScheduledAt: state.nextScheduledAt,
|
||||
defaultEndpoint: DEFAULT_ENDPOINT,
|
||||
payloadPreview: payload,
|
||||
accountCounts,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('telemetry GET error', {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/telemetry
|
||||
* Body: { action: 'set-consent' | 'set-endpoint' | 'send-now', ... }
|
||||
* set-consent : { action, consent: 'on' | 'off' }
|
||||
* set-endpoint : { action, endpoint: string }
|
||||
* send-now : { action }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
const body = (await request.json().catch(() => null)) as
|
||||
| { action?: string; consent?: string; endpoint?: string }
|
||||
| null;
|
||||
if (!body || typeof body.action !== 'string') {
|
||||
return NextResponse.json({ error: 'action required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { source } = await effectiveConsent();
|
||||
|
||||
if (body.action === 'set-consent') {
|
||||
if (source === 'env') {
|
||||
return NextResponse.json(
|
||||
{ error: 'consent is overridden by BULWARK_TELEMETRY env var' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (body.consent !== 'on' && body.consent !== 'off') {
|
||||
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
|
||||
}
|
||||
const state = await loadState();
|
||||
const before = state.consent;
|
||||
state.consent = body.consent;
|
||||
if (body.consent === 'on' && !state.consentedAt) {
|
||||
state.consentedAt = new Date().toISOString();
|
||||
}
|
||||
await saveState(state);
|
||||
await reschedule();
|
||||
await auditLog('telemetry.set-consent', { from: before, to: body.consent }, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (body.action === 'set-endpoint') {
|
||||
if (typeof body.endpoint !== 'string') {
|
||||
return NextResponse.json({ error: 'endpoint required' }, { status: 400 });
|
||||
}
|
||||
const trimmed = body.endpoint.trim();
|
||||
if (trimmed) {
|
||||
const check = await resolveEndpointAllowed(trimmed);
|
||||
if (!check.ok) {
|
||||
return NextResponse.json({ error: check.reason }, { status: 400 });
|
||||
}
|
||||
}
|
||||
const state = await loadState();
|
||||
const before = state.endpoint;
|
||||
state.endpoint = trimmed || DEFAULT_ENDPOINT;
|
||||
await saveState(state);
|
||||
await auditLog('telemetry.set-endpoint', { from: before, to: state.endpoint }, ip);
|
||||
return NextResponse.json({ ok: true, endpoint: state.endpoint });
|
||||
}
|
||||
|
||||
if (body.action === 'send-now') {
|
||||
const result = await sendOnce({ reason: 'admin-manual' });
|
||||
await auditLog(
|
||||
'telemetry.send-now',
|
||||
{ ok: result.ok, status: result.status ?? null, error: result.error ?? null },
|
||||
ip,
|
||||
);
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
|
||||
} catch (err) {
|
||||
logger.error('telemetry POST error', {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,9 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
/**
|
||||
* GET /api/admin/themes - List all admin-managed themes
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const registry = await getThemeRegistry();
|
||||
@@ -36,7 +36,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -156,7 +156,7 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -193,7 +193,7 @@ export async function PATCH(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
loadState,
|
||||
checkOnce,
|
||||
effectiveEndpoint,
|
||||
disabledByEnv,
|
||||
DEFAULT_VERSION_ENDPOINT,
|
||||
} from '@/lib/version-check';
|
||||
|
||||
/**
|
||||
* GET /api/admin/version
|
||||
* Returns the cached update status, last check times, and effective config.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const state = await loadState();
|
||||
return NextResponse.json(
|
||||
{
|
||||
current: process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0',
|
||||
build: process.env.NEXT_PUBLIC_GIT_COMMIT || 'unknown',
|
||||
endpoint: effectiveEndpoint(state),
|
||||
defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
|
||||
disabledByEnv: disabledByEnv(),
|
||||
lastCheckedAt: state.lastCheckedAt,
|
||||
lastSuccessAt: state.lastSuccessAt,
|
||||
nextScheduledAt: state.nextScheduledAt,
|
||||
status: state.status,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('version admin GET error', {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/version
|
||||
* { action: 'check-now' } - force a fresh upstream fetch.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth(req);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { action?: string } | null;
|
||||
if (!body || body.action !== 'check-now') {
|
||||
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await checkOnce({ reason: 'admin-trigger' });
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
logger.error('version admin POST error', {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptSession } from '@/lib/auth/crypto';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth';
|
||||
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import {
|
||||
ImpersonationJwtError,
|
||||
impersonationReplayCache,
|
||||
verifyImpersonationJwt,
|
||||
} from '@/lib/impersonation/jwt';
|
||||
import {
|
||||
readImpersonationConfig,
|
||||
resolveImpersonationServerUrl,
|
||||
} from '@/lib/impersonation/master-config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const IMPERSONATION_SLOT = 0;
|
||||
|
||||
/**
|
||||
* Impersonation cookies deliberately omit Max-Age so the browser treats
|
||||
* them as session cookies - the impersonated session ends when the user
|
||||
* closes the browser, not 30 days later. Impersonation is a temporary
|
||||
* support handoff; a normal password login is the only thing that should
|
||||
* survive a browser restart.
|
||||
*/
|
||||
function impersonationCookieOptions() {
|
||||
const { maxAge: _maxAge, ...rest } = getCookieOptions();
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/impersonate?token=<jwt>
|
||||
*
|
||||
* Master-user impersonation via signed JWT. The token carries the target
|
||||
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
||||
* master credentials from env, then mints the same session cookies the
|
||||
* password-login path produces. The browser is redirected to "/" and the
|
||||
* SPA hydrates as if the user had just logged in with master@target%master.
|
||||
*
|
||||
* Returns 404 when the feature is not configured so an unconfigured
|
||||
* deployment does not advertise the endpoint.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const config = readImpersonationConfig();
|
||||
if (!config) {
|
||||
// Not configured - behave exactly like an unknown route.
|
||||
return new NextResponse('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = request.nextUrl.searchParams.get('token');
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Missing token' }, { status: 400 });
|
||||
}
|
||||
|
||||
let claims;
|
||||
try {
|
||||
claims = verifyImpersonationJwt(token, config.jwtSecret, {
|
||||
expectedIssuer: config.expectedIssuer,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ImpersonationJwtError) {
|
||||
logger.warn('Impersonation JWT rejected', { code: err.code });
|
||||
return NextResponse.json({ error: err.message }, { status: err.status });
|
||||
}
|
||||
logger.error('Impersonation JWT error', {
|
||||
error: err instanceof Error ? err.message : 'Unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!impersonationReplayCache.consume(claims.jti, claims.exp)) {
|
||||
logger.warn('Impersonation JWT replay rejected', { jti: claims.jti });
|
||||
return NextResponse.json({ error: 'Token already used' }, { status: 401 });
|
||||
}
|
||||
|
||||
const serverUrl = await resolveImpersonationServerUrl();
|
||||
if (!serverUrl) {
|
||||
logger.error('Impersonation requested but jmapServerUrl is not configured');
|
||||
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
let normalizedServerUrl: string;
|
||||
try {
|
||||
normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Stalwart master-user impersonation: username = "<target>%<master>",
|
||||
// password = <master_password>. Per Stalwart docs:
|
||||
// https://stalw.art/docs/auth/authorization/administrator/
|
||||
const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`;
|
||||
const authHeader = `Basic ${Buffer.from(
|
||||
`${impersonatedUsername}:${config.masterPassword}`,
|
||||
).toString('base64')}`;
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = encryptSession(
|
||||
normalizedServerUrl,
|
||||
impersonatedUsername,
|
||||
config.masterPassword,
|
||||
);
|
||||
cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, impersonationCookieOptions());
|
||||
setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username: impersonatedUsername,
|
||||
authHeader,
|
||||
});
|
||||
|
||||
// Structured audit log - operators rely on this for security review.
|
||||
logger.info('Impersonation session granted', {
|
||||
event: 'impersonation_granted',
|
||||
jti: claims.jti,
|
||||
mailbox: claims.mailbox,
|
||||
tenant_id: claims.tenant_id,
|
||||
actor_user_id: claims.actor_user_id,
|
||||
iss: claims.iss,
|
||||
ip:
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
null,
|
||||
referer: request.headers.get('referer'),
|
||||
user_agent: request.headers.get('user-agent'),
|
||||
});
|
||||
|
||||
void recordLogin(impersonatedUsername, normalizedServerUrl);
|
||||
|
||||
// Use a relative Location header so the browser resolves it against the
|
||||
// public request URL. NextResponse.redirect(new URL('/', request.url))
|
||||
// would absolutise to the container's internal bind (http://0.0.0.0:3000)
|
||||
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
|
||||
return new NextResponse(null, {
|
||||
status: 303,
|
||||
headers: { Location: '/' },
|
||||
});
|
||||
}
|
||||
@@ -4,28 +4,42 @@ import { logger } from '@/lib/logger';
|
||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import {
|
||||
JmapAuthVerificationError,
|
||||
normalizeJmapServerUrl,
|
||||
validateProxyAuthHeader,
|
||||
verifyJmapAuth,
|
||||
} from '@/lib/auth/verify-jmap-auth';
|
||||
import {
|
||||
clearStalwartAuthContextInStore,
|
||||
setStalwartAuthContextInStore,
|
||||
} from '@/lib/stalwart/auth-context';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
...getCookieOptions(),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
if (raw === null) return 0;
|
||||
const slot = parseInt(raw, 10);
|
||||
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
||||
if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0;
|
||||
return slot;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
|
||||
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
|
||||
if (oauthEnabled && oauthOnly) {
|
||||
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -34,19 +48,57 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||
// Pin the upstream URL to a configured JMAP server so an unauthenticated
|
||||
// caller cannot point this route at internal hosts. We accept the global
|
||||
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
|
||||
// we fall back to the request URL only if `allowCustomJmapEndpoint` is on
|
||||
// - and even then the URL must resolve to a public address.
|
||||
await configManager.ensureLoaded();
|
||||
const configuredServerUrl =
|
||||
configManager.get<string>('jmapServerUrl', '') ||
|
||||
process.env.JMAP_SERVER_URL ||
|
||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||
'';
|
||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
|
||||
|
||||
let upstreamUrl: string;
|
||||
let upstreamTrusted: boolean;
|
||||
if (trustedUrl) {
|
||||
upstreamUrl = trustedUrl;
|
||||
upstreamTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
|
||||
}
|
||||
upstreamUrl = serverUrl;
|
||||
upstreamTrusted = false;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
|
||||
const cookieName = sessionCookieName(slot);
|
||||
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
||||
// Trusted (admin-configured) URLs skip the upstream re-fetch: the cookie
|
||||
// we write here is only ever consumed for requests on behalf of this same
|
||||
// user, so bogus credentials would just yield 401s downstream rather than
|
||||
// privilege escalation. Untrusted custom endpoints still verify upstream.
|
||||
const normalizedServerUrl = upstreamTrusted
|
||||
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl))
|
||||
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||
const token = encryptSession(normalizedServerUrl, username, password);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, token, sessionCookieOptions());
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username,
|
||||
authHeader,
|
||||
});
|
||||
|
||||
void recordLogin(username, normalizedServerUrl);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof JmapAuthVerificationError) {
|
||||
@@ -147,8 +199,8 @@ export async function DELETE(request: NextRequest) {
|
||||
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||
|
||||
if (all) {
|
||||
// Delete all session cookies (slots 0-4)
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
// Delete all session cookies across every slot.
|
||||
for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
|
||||
cookieStore.delete(sessionCookieName(i));
|
||||
clearStalwartAuthContextInStore(cookieStore, i);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { decryptPayload } from '@/lib/auth/crypto';
|
||||
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
import {
|
||||
exchangeCodeForTokens,
|
||||
getRequiredConfig,
|
||||
getTokenEndpoint,
|
||||
} from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
@@ -13,12 +17,18 @@ export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
try {
|
||||
const { code, state } = await request.json();
|
||||
const { code, state, slot: bodySlot } = await request.json();
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Per-account refresh-token cookie slot. Without this the route hardcoded
|
||||
// slot 0, so the "+ Add Account" flow overwrote the first account's
|
||||
// refresh-token cookie. Default to 0 for back-compat with any caller that
|
||||
// omits slot. Mirrors the validation in /api/auth/token POST.
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||
|
||||
// Read and decrypt the pending SSO cookie
|
||||
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
||||
if (!pendingCookie) {
|
||||
@@ -49,6 +59,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const codeVerifier = pending.code_verifier as string;
|
||||
const redirectUri = pending.redirect_uri as string;
|
||||
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
||||
const mobileRedirectUri =
|
||||
typeof pending.mobile_redirect_uri === 'string' ? pending.mobile_redirect_uri : null;
|
||||
const mobileState = typeof pending.mobile_state === 'string' ? pending.mobile_state : null;
|
||||
const isMobileFlow = Boolean(mobileRedirectUri);
|
||||
|
||||
if (!codeVerifier || !redirectUri) {
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
@@ -56,17 +71,48 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||
|
||||
// Store refresh token
|
||||
// For the mobile handoff flow the tokens are handed back to the app
|
||||
// verbatim - we deliberately don't write any cookies on the webmail
|
||||
// origin (the mobile browser tab disposes of the session after the
|
||||
// redirect anyway, but the cookie would still get committed to the
|
||||
// user's main webmail session if they happened to be logged in there).
|
||||
if (!isMobileFlow) {
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(0);
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (pendingServerId) {
|
||||
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete pending cookie
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
|
||||
if (isMobileFlow) {
|
||||
// The mobile client needs the bits it can't re-derive: the refresh
|
||||
// token, the token endpoint it should hit to refresh later, and the
|
||||
// client_id the IdP expects on that refresh call. The server URL is
|
||||
// returned so the app knows which JMAP host to connect to.
|
||||
const { clientId, serverUrl } = getRequiredConfig(pendingServerId);
|
||||
const tokenEndpoint = await getTokenEndpoint(pendingServerId);
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_endpoint: tokenEndpoint,
|
||||
client_id: clientId,
|
||||
server_url: serverUrl,
|
||||
mobile_redirect_uri: mobileRedirectUri,
|
||||
mobile_state: mobileState,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
|
||||
@@ -5,25 +5,50 @@ import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
// The mobile app's deep-link scheme. Only redirect targets starting with
|
||||
// this prefix may flow through the mobile handoff path; without the guard
|
||||
// the SSO complete route would be coerced into returning tokens to whatever
|
||||
// caller-controlled URL the attacker chose.
|
||||
const MOBILE_REDIRECT_SCHEME = 'bulwarkmobile://';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
|
||||
if (!hasSessionSecret()) {
|
||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { redirect_uri, locale } = await request.json();
|
||||
const {
|
||||
redirect_uri,
|
||||
locale,
|
||||
server_id: bodyServerId,
|
||||
mobile_redirect_uri: rawMobileRedirectUri,
|
||||
mobile_state: rawMobileState,
|
||||
} = await request.json();
|
||||
|
||||
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const mobileRedirectUri =
|
||||
typeof rawMobileRedirectUri === 'string' && rawMobileRedirectUri
|
||||
? rawMobileRedirectUri
|
||||
: null;
|
||||
const mobileState =
|
||||
typeof rawMobileState === 'string' && rawMobileState ? rawMobileState : null;
|
||||
if (mobileRedirectUri && !mobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)) {
|
||||
return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
||||
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
|
||||
try {
|
||||
@@ -36,8 +61,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { clientId, discoveryUrl } = getRequiredConfig();
|
||||
const metadata = await discoverOAuth(discoveryUrl);
|
||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
|
||||
if (!metadata?.authorization_endpoint) {
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
@@ -48,12 +73,19 @@ export async function POST(request: NextRequest) {
|
||||
const codeChallenge = generateCodeChallengeServer(codeVerifier);
|
||||
const state = generateStateServer();
|
||||
|
||||
// Encrypt and store in httpOnly cookie
|
||||
// Encrypt and store in httpOnly cookie. server_id is captured here so the
|
||||
// /complete handler reaches the same OAuth endpoint we used to authorize.
|
||||
// Mobile params are captured here so /complete knows to return tokens to
|
||||
// the caller (in the JSON response) instead of writing the usual server
|
||||
// cookies - and so the callback page can redirect back to the app.
|
||||
const pendingData = {
|
||||
state,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri,
|
||||
created_at: Date.now(),
|
||||
...(serverId ? { server_id: serverId } : {}),
|
||||
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
|
||||
...(mobileState ? { mobile_state: mobileState } : {}),
|
||||
};
|
||||
|
||||
const encrypted = encryptPayload(pendingData);
|
||||
@@ -69,7 +101,7 @@ export async function POST(request: NextRequest) {
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', clientId);
|
||||
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
||||
authUrl.searchParams.set('scope', OAUTH_SCOPES);
|
||||
authUrl.searchParams.set('scope', getOauthScopes());
|
||||
authUrl.searchParams.set('state', state);
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import { JmapAuthVerificationError, assertBasicAuthMatchesUsername, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
|
||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS) {
|
||||
return bodySlot;
|
||||
}
|
||||
|
||||
@@ -12,7 +17,7 @@ function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||
if (raw === null) return 0;
|
||||
|
||||
const slot = parseInt(raw, 10);
|
||||
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
|
||||
return Number.isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS ? 0 : slot;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -23,8 +28,51 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Pin the upstream URL to a configured JMAP server (single `jmapServerUrl`
|
||||
// or any entry in `jmapServers`). Falls back to the request URL only when
|
||||
// `allowCustomJmapEndpoint` is enabled, and even then it must be public.
|
||||
await configManager.ensureLoaded();
|
||||
const configuredServerUrl =
|
||||
configManager.get<string>('jmapServerUrl', '') ||
|
||||
process.env.JMAP_SERVER_URL ||
|
||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||
'';
|
||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
|
||||
|
||||
let upstreamUrl: string;
|
||||
let upstreamTrusted: boolean;
|
||||
if (trustedUrl) {
|
||||
upstreamUrl = trustedUrl;
|
||||
upstreamTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
|
||||
}
|
||||
upstreamUrl = serverUrl;
|
||||
upstreamTrusted = false;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const slot = getSlot(request, bodySlot);
|
||||
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
||||
// Trusted (admin-configured) URLs skip the upstream re-fetch, but we
|
||||
// still bind the cookie's `username` to the credential when we can verify
|
||||
// locally. Without this, a caller can POST username="admin@host" +
|
||||
// authHeader=<their own Basic creds>, and downstream consumers that read
|
||||
// the cookie-derived username (audit logs, login tracker) accept the
|
||||
// spoof. Bearer tokens are opaque so only the format check runs;
|
||||
// authorization sinks must key off the credential itself, not the
|
||||
// cookie's username claim (see admin/auth's authHeader-hashed cache key).
|
||||
let normalizedServerUrl: string;
|
||||
if (upstreamTrusted) {
|
||||
validateProxyAuthHeader(authHeader);
|
||||
assertBasicAuthMatchesUsername(authHeader, username);
|
||||
normalizedServerUrl = normalizeJmapServerUrl(upstreamUrl);
|
||||
} else {
|
||||
normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||
}
|
||||
|
||||
await setStalwartAuthContext(slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
@@ -32,6 +80,8 @@ export async function POST(request: NextRequest) {
|
||||
authHeader,
|
||||
});
|
||||
|
||||
void recordLogin(username, normalizedServerUrl);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof JmapAuthVerificationError) {
|
||||
|
||||
+32
-14
@@ -1,40 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
if (raw === null) return 0;
|
||||
const slot = parseInt(raw, 10);
|
||||
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
||||
if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0;
|
||||
return slot;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
|
||||
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
|
||||
if (!code || !code_verifier || !redirect_uri) {
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
|
||||
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri);
|
||||
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
|
||||
|
||||
const response = NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
// Persist which server entry minted this refresh token so the PUT/DELETE
|
||||
// handlers can route the refresh/revocation calls to the right token
|
||||
// endpoint without the client having to track it across page loads.
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (serverId) {
|
||||
cookieStore.set(serverCookieName, serverId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
@@ -49,17 +60,18 @@ export async function PUT(request: NextRequest) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await getTokenEndpoint();
|
||||
const tokenEndpoint = await getTokenEndpoint(serverId);
|
||||
|
||||
const params = buildOAuthParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
}, serverId);
|
||||
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
@@ -71,6 +83,7 @@ export async function PUT(request: NextRequest) {
|
||||
const errorText = await tokenResponse.text();
|
||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||
cookieStore.delete(cookieName);
|
||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -100,17 +113,19 @@ export async function DELETE(request: NextRequest) {
|
||||
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||
|
||||
if (all) {
|
||||
// Revoke and delete all refresh token cookies (slots 0-4)
|
||||
// Revoke and delete all refresh token cookies across every slot.
|
||||
const cookieStore = await cookies();
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
|
||||
const name = refreshTokenCookieName(i);
|
||||
const serverCookieName = refreshTokenServerCookieName(i);
|
||||
const token = cookieStore.get(name)?.value;
|
||||
const slotServerId = cookieStore.get(serverCookieName)?.value || null;
|
||||
if (token) {
|
||||
// Best-effort revocation
|
||||
try {
|
||||
const metadata = await getMetadata().catch(() => null);
|
||||
const metadata = await getMetadata(slotServerId).catch(() => null);
|
||||
if (metadata?.revocation_endpoint) {
|
||||
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' });
|
||||
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' }, slotServerId);
|
||||
await fetch(metadata.revocation_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
@@ -120,6 +135,7 @@ export async function DELETE(request: NextRequest) {
|
||||
} catch { /* best effort */ }
|
||||
cookieStore.delete(name);
|
||||
}
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -128,7 +144,8 @@ export async function DELETE(request: NextRequest) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||
const metadata = await getMetadata().catch((err) => {
|
||||
const slotServerId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||
const metadata = await getMetadata(slotServerId).catch((err) => {
|
||||
logger.warn('Failed to discover OAuth metadata during logout', {
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
@@ -140,7 +157,7 @@ export async function DELETE(request: NextRequest) {
|
||||
const params = buildOAuthParams({
|
||||
token: refreshToken,
|
||||
token_type_hint: 'refresh_token',
|
||||
});
|
||||
}, slotServerId);
|
||||
|
||||
try {
|
||||
const revocationResponse = await fetch(metadata.revocation_endpoint, {
|
||||
@@ -158,6 +175,7 @@ export async function DELETE(request: NextRequest) {
|
||||
|
||||
cookieStore.delete(cookieName);
|
||||
}
|
||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||
|
||||
let end_session_url: string | undefined;
|
||||
if (metadata?.end_session_endpoint) {
|
||||
|
||||
@@ -2,9 +2,14 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
|
||||
/**
|
||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||
@@ -49,7 +54,7 @@ async function tryTokenRequest(
|
||||
|
||||
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||
// 1. Try OAuth discovery
|
||||
const metadata = await discoverOAuth(serverUrl);
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||
|
||||
// 2. Try common Stalwart token endpoint paths directly
|
||||
@@ -75,30 +80,56 @@ async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
||||
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
|
||||
if (!serverUrl || !username || !password) {
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
|
||||
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
// Use the server-side JMAP_SERVER_URL if set (may differ from the
|
||||
// public URL the browser uses, e.g. inside Docker).
|
||||
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl;
|
||||
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
||||
// no server is configured (and the deployment explicitly allows custom
|
||||
// JMAP endpoints) do we fall back to the user-supplied URL - and even then
|
||||
// it must resolve to a public address.
|
||||
await configManager.ensureLoaded();
|
||||
const configuredServerUrl =
|
||||
configManager.get<string>('jmapServerUrl', '') ||
|
||||
process.env.JMAP_SERVER_URL ||
|
||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||
'';
|
||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
|
||||
const tokenEndpoint = await findTokenEndpoint(internalServerUrl);
|
||||
let upstreamUrl: string;
|
||||
let resolvedServerId: string | null = null;
|
||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||
|
||||
if (matchedEntry) {
|
||||
upstreamUrl = matchedEntry.url;
|
||||
resolvedServerId = matchedEntry.id;
|
||||
} else if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
||||
}
|
||||
upstreamUrl = serverUrl;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
|
||||
if (!tokenEndpoint) {
|
||||
// Also try with the client-provided URL in case the internal one differs
|
||||
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null;
|
||||
if (!clientEndpoint) {
|
||||
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl });
|
||||
logger.warn('TOTP token exchange: no token endpoint found');
|
||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||
}
|
||||
return await attemptAllStrategies(clientEndpoint, username, password, slot);
|
||||
}
|
||||
|
||||
return await attemptAllStrategies(tokenEndpoint, username, password, slot);
|
||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
|
||||
} catch (error) {
|
||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
@@ -107,14 +138,25 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
async function attemptAllStrategies(
|
||||
tokenEndpoint: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||
// Per-server OAuth credentials override the global ones when the requested
|
||||
// server entry has its own oauth block configured.
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const entry = findServerById(serverList, serverId);
|
||||
const clientId = entry?.oauth?.clientId
|
||||
|| configManager.get<string>('oauthClientId', '')
|
||||
|| process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = entry?.oauth?.clientSecret
|
||||
|| configManager.get<string>('oauthClientSecret', '')
|
||||
|| process.env.OAUTH_CLIENT_SECRET
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||
|
||||
@@ -125,7 +167,8 @@ async function attemptAllStrategies(
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||
}
|
||||
@@ -136,7 +179,8 @@ async function attemptAllStrategies(
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||
}
|
||||
@@ -147,7 +191,8 @@ async function attemptAllStrategies(
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||
}
|
||||
@@ -158,7 +203,8 @@ async function attemptAllStrategies(
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||
}
|
||||
@@ -174,12 +220,19 @@ async function attemptAllStrategies(
|
||||
async function storeAndRespond(
|
||||
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
const cookieStore = await cookies();
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (serverId) {
|
||||
cookieStore.set(serverCookieName, serverId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
@@ -34,8 +36,9 @@ export async function GET() {
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
|
||||
oauthScopes: getOauthScopes(),
|
||||
rememberMeEnabled: hasSessionSecret(),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
@@ -49,6 +52,8 @@ export async function GET() {
|
||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
|
||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
|
||||
@@ -106,16 +106,15 @@ const emails: MockEmail[] = [
|
||||
// =====================================================================
|
||||
{
|
||||
id: 'email-001', threadId: 'thread-001', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4200, receivedAt: daysAgo(0),
|
||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
||||
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||
subject: 'Willkommen bei Bulwark Webmail!',
|
||||
preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.',
|
||||
preview: 'Hallo! Welcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on JMAP.',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
|
||||
htmlBody: [{ partId: 'p2', blobId: 'blob-002', size: 420, type: 'text/html' }],
|
||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 2200, type: 'text/plain' }],
|
||||
htmlBody: [],
|
||||
bodyValues: {
|
||||
p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' },
|
||||
p2: { value: '<p>Hallo!</p><p>This is a sample email to help you get started with the <strong>Bulwark Webmail</strong> development environment.</p><p>Feel free to explore the UI - all data here is mock data.</p><p>Beste Grüße,<br>Sophie</p>' },
|
||||
p1: { value: 'Hallo!\n\nWelcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on the JMAP protocol. No PHP, no 2008 architecture, no plugin-of-plugins archaeology; just clean TypeScript and Next.js, instant push, and a UI that feels like a native app instead of a Gmail polyfill.\n\nWhy JMAP matters: one TLS connection instead of long-polling, push notifications the moment new mail arrives, batched mutations so a click never waits on three round-trips, and threading stitched on the server rather than reassembled in the browser. The result is a webmail that feels quick on a flaky train Wi-Fi and quicker on fibre.\n\nMail, calendar, contacts, and files - everything Stalwart already serves, surfaced through a single window. Threaded inbox with full-text search and Sieve filters. Month, week, day and agenda views with recurring events and iMIP invitations. Multiple address books with vCard import and export. File previews backed by Stalwart\'s JMAP FileNode storage. S/MIME, templates, keyboard shortcuts, dark mode, dozens of languages - the boring stuff that should just work, working.\n\nTwo containers behind your reverse proxy of choice is all it takes to host it yourself: Stalwart for the server side, Bulwark for the client. Caddy, Traefik, nginx - pick one, there are working examples for each. Stalwart stays the source of truth, Bulwark is what you point your browser at, and the setup wizard handles the parts that would otherwise live in a config file.\n\nIt is AGPL, the codebase is small enough to read in an afternoon, and the extension directory already hosts a growing collection of plugins and themes. If something is missing, you can fork it, file an issue, or send a patch - a person will read it.\n\nBeste Grüße,\nSophie' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -198,7 +197,7 @@ const emails: MockEmail[] = [
|
||||
id: 'email-014', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(2),
|
||||
from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
subject: 'Sprint planning - next week priorities',
|
||||
preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.',
|
||||
hasAttachment: false,
|
||||
@@ -368,7 +367,7 @@ const emails: MockEmail[] = [
|
||||
},
|
||||
{
|
||||
id: 'email-026', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 2400, receivedAt: hoursAgo(1),
|
||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
||||
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||
cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
subject: 'Re: Sprint planning - next week priorities',
|
||||
@@ -472,7 +471,7 @@ const emails: MockEmail[] = [
|
||||
{
|
||||
id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5),
|
||||
from: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
to: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], cc: [],
|
||||
to: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], cc: [],
|
||||
subject: 'Design review feedback',
|
||||
preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.',
|
||||
hasAttachment: false,
|
||||
@@ -486,7 +485,7 @@ const emails: MockEmail[] = [
|
||||
id: 'email-027', threadId: 'thread-013', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1900, receivedAt: hoursAgo(0.5),
|
||||
from: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
subject: 'Re: Sprint planning - next week priorities',
|
||||
preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.',
|
||||
hasAttachment: false,
|
||||
@@ -640,7 +639,7 @@ const emails: MockEmail[] = [
|
||||
},
|
||||
{
|
||||
id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30),
|
||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
||||
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||
subject: 'Conference talk accepted!',
|
||||
preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!',
|
||||
@@ -729,8 +728,8 @@ const IDENTITIES = [
|
||||
email: 'dev@localhost',
|
||||
replyTo: null,
|
||||
bcc: null,
|
||||
textSignature: '-- \nDev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>--<br>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
textSignature: 'Dev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
mayDelete: false,
|
||||
},
|
||||
];
|
||||
@@ -744,6 +743,12 @@ const addressBooks = [
|
||||
{ id: 'ab-2', name: 'Arbeit / Work', isDefault: false },
|
||||
];
|
||||
|
||||
// Profile photos served straight from randomuser.me's CDN; the API at
|
||||
// https://randomuser.me/api/ also returns these portrait URLs, but for a
|
||||
// fixed mock dataset we link them directly to keep things offline-friendly.
|
||||
// See https://randomuser.me/documentation#howto
|
||||
const PORTRAIT = (gender: 'men' | 'women', n: number) => `https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
|
||||
|
||||
const contacts = [
|
||||
// --- Personal address book ---
|
||||
{ id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
@@ -753,6 +758,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'EuroTech GmbH' } },
|
||||
addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } },
|
||||
notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 14), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] },
|
||||
@@ -761,6 +767,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Dubois Consulting' } },
|
||||
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
||||
notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 23), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] },
|
||||
@@ -769,6 +776,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Rossi Design Studio' } },
|
||||
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
|
||||
notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 40), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] },
|
||||
@@ -776,6 +784,7 @@ const contacts = [
|
||||
phones: { p1: { number: '+31 20 555 0142' } },
|
||||
addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } },
|
||||
notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 45), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] },
|
||||
@@ -784,6 +793,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||
addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } },
|
||||
notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 61), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] },
|
||||
@@ -792,6 +802,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||
addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } },
|
||||
notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 29), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] },
|
||||
@@ -799,6 +810,7 @@ const contacts = [
|
||||
phones: { p1: { number: '+39 06 9876 5432' } },
|
||||
addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } },
|
||||
notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 72), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] },
|
||||
@@ -807,6 +819,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'BergLabs' } },
|
||||
addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } },
|
||||
notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 58), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] },
|
||||
@@ -815,6 +828,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Nielsen Konsult' } },
|
||||
addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } },
|
||||
notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 35), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] },
|
||||
@@ -823,6 +837,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Sorbonne Université' } },
|
||||
addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } },
|
||||
notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 63), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
// --- Work address book ---
|
||||
{ id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
@@ -832,6 +847,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Lefèvre & Associés' } },
|
||||
addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
||||
notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 81), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] },
|
||||
@@ -840,6 +856,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Charité Klinik Berlin' } },
|
||||
addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } },
|
||||
notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 26), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] },
|
||||
@@ -848,6 +865,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Finanz Dublin' } },
|
||||
addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } },
|
||||
notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 19), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] },
|
||||
@@ -856,6 +874,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'García Design Studio' } },
|
||||
addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } },
|
||||
notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 50), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
|
||||
@@ -864,6 +883,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Digitaal BV' } },
|
||||
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
|
||||
notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 57), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] },
|
||||
@@ -872,6 +892,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Kowalska Marketing' } },
|
||||
addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } },
|
||||
notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 71), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] },
|
||||
@@ -880,6 +901,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Murphy Bau GmbH' } },
|
||||
addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } },
|
||||
notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 93), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] },
|
||||
@@ -888,6 +910,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Ferreira Media' } },
|
||||
addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } },
|
||||
notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 82), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] },
|
||||
@@ -896,6 +919,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Dumont Conseil' } },
|
||||
addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } },
|
||||
notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 4), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] },
|
||||
@@ -905,6 +929,7 @@ const contacts = [
|
||||
addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } },
|
||||
nicknames: { n1: { name: 'Anni' } },
|
||||
notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 36), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
// --- Groups ---
|
||||
{ id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
|
||||
@@ -977,7 +1002,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
},
|
||||
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } },
|
||||
@@ -987,7 +1012,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||
},
|
||||
@@ -1025,7 +1050,7 @@ const calendarEvents = [
|
||||
virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } },
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p2: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
},
|
||||
description: 'Discuss API rate limit escalation for EuroTech enterprise account.',
|
||||
@@ -1055,7 +1080,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||
p6: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
@@ -1067,7 +1092,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost'),
|
||||
p2: participant('María García', 'maria@garcia-design.example', 'owner'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
},
|
||||
}),
|
||||
makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', {
|
||||
@@ -1085,7 +1110,7 @@ const calendarEvents = [
|
||||
p2: participant('Dev User', 'dev@localhost'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
||||
p5: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p5: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
},
|
||||
}),
|
||||
makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', {
|
||||
@@ -1094,7 +1119,7 @@ const calendarEvents = [
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
p4: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p4: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
},
|
||||
}),
|
||||
makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', {
|
||||
@@ -1110,7 +1135,7 @@ const calendarEvents = [
|
||||
location: 'Sophie\'s apartment, Kreuzberg, Berlin',
|
||||
description: 'Annual Eurovision Song Contest watch party!\n\nRules:\n1. Scorecards mandatory (printed copies provided)\n2. Drink when someone says "douze points"\n3. Best costume contest (prize: a waffle iron)\n4. No spoilers from the semis!\n\nBring: snacks from your home country.',
|
||||
participants: {
|
||||
p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'),
|
||||
p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'),
|
||||
p2: participant('Dev User', 'dev@localhost'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
||||
@@ -1193,7 +1218,7 @@ const calendarEvents = [
|
||||
}),
|
||||
|
||||
// ===== Birthday calendar (cal-5) =====
|
||||
makeEvent('evt-030', 'cal-5', '🎂 Sophie Müller', localDateTime(8, 0, 0), 'P1D', {
|
||||
makeEvent('evt-030', 'cal-5', '🎂 Sophie Example', localDateTime(8, 0, 0), 'P1D', {
|
||||
showWithoutTime: true,
|
||||
recurrence: [{ frequency: 'yearly' }],
|
||||
description: 'Don\'t forget to bring Kuchen!',
|
||||
@@ -1221,7 +1246,7 @@ const calendarEvents = [
|
||||
description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!',
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost'),
|
||||
p2: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p2: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'),
|
||||
},
|
||||
}),
|
||||
|
||||
+33
-81
@@ -1,87 +1,27 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
const blockedAddressRanges = new BlockList();
|
||||
blockedAddressRanges.addAddress('0.0.0.0');
|
||||
blockedAddressRanges.addAddress('127.0.0.1');
|
||||
blockedAddressRanges.addSubnet('10.0.0.0', 8);
|
||||
blockedAddressRanges.addSubnet('172.16.0.0', 12);
|
||||
blockedAddressRanges.addSubnet('192.168.0.0', 16);
|
||||
blockedAddressRanges.addSubnet('169.254.0.0', 16);
|
||||
blockedAddressRanges.addAddress('::', 'ipv6');
|
||||
blockedAddressRanges.addAddress('::1', 'ipv6');
|
||||
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
|
||||
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
||||
}
|
||||
|
||||
function isBlockedIpAddress(hostname: string): boolean {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
const family = isIP(normalized);
|
||||
if (family === 4) {
|
||||
return blockedAddressRanges.check(normalized, 'ipv4');
|
||||
}
|
||||
if (family === 6) {
|
||||
return blockedAddressRanges.check(normalized, 'ipv6');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function isValidExternalUrl(urlString: string): Promise<boolean> {
|
||||
let url: URL;
|
||||
function extractBasicAuth(rawUrl: string): { cleanUrl: string; authHeader: string | null } | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
|
||||
// Block private/internal hostnames
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname.endsWith('.localhost') ||
|
||||
hostname.endsWith('.local') ||
|
||||
hostname.endsWith('.internal') ||
|
||||
hostname.endsWith('.arpa') ||
|
||||
hostname.endsWith('.localdomain')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block URLs with credentials
|
||||
if (url.username || url.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isBlockedIpAddress(hostname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isIP(hostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await lookup(hostname, { all: true, verbatim: true });
|
||||
if (records.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return records.every((record) => !isBlockedIpAddress(record.address));
|
||||
} catch {
|
||||
return false;
|
||||
let authHeader: string | null = null;
|
||||
if (parsed.username || parsed.password) {
|
||||
const username = decodeURIComponent(parsed.username);
|
||||
const password = decodeURIComponent(parsed.password);
|
||||
authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
}
|
||||
|
||||
return { cleanUrl: parsed.toString(), authHeader };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -98,7 +38,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(await isValidExternalUrl(url))) {
|
||||
const extracted = extractBasicAuth(url);
|
||||
if (!extracted) {
|
||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { cleanUrl, authHeader } = extracted;
|
||||
|
||||
if (!(await isPublicHttpUrl(cleanUrl))) {
|
||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -107,21 +54,27 @@ export async function POST(request: NextRequest) {
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
let currentUrl = url;
|
||||
let currentUrl = cleanUrl;
|
||||
const originalOrigin = new URL(cleanUrl).origin;
|
||||
let response: Response | undefined;
|
||||
|
||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
if (!(await isValidExternalUrl(currentUrl))) {
|
||||
if (!(await isPublicHttpUrl(currentUrl))) {
|
||||
clearTimeout(timeout);
|
||||
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
response = await fetch(currentUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||
},
|
||||
};
|
||||
if (authHeader && new URL(currentUrl).origin === originalOrigin) {
|
||||
headers['Authorization'] = authHeader;
|
||||
}
|
||||
|
||||
response = await fetch(currentUrl, {
|
||||
signal: controller.signal,
|
||||
headers,
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
@@ -131,7 +84,6 @@ export async function POST(request: NextRequest) {
|
||||
clearTimeout(timeout);
|
||||
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
||||
}
|
||||
// Resolve relative redirects
|
||||
currentUrl = new URL(location, currentUrl).toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { decryptSession } from '@/lib/auth/crypto';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/admin/plugin-approvals';
|
||||
|
||||
/**
|
||||
* GET /api/plugin-approval-status?pluginId=X&bundleHash=Y
|
||||
*
|
||||
* Any logged-in user may query the server-side approval state for a plugin
|
||||
* they want to enable. The client uses this BEFORE running `enablePlugin`
|
||||
* when the `requirePluginApproval` policy is set.
|
||||
*
|
||||
* POST same path with body `{ pluginId, bundleHash, manifest }` creates a
|
||||
* pending approval entry (or returns the existing one).
|
||||
*/
|
||||
|
||||
async function resolveUsername(): Promise<string | null> {
|
||||
const cookieStore = await cookies();
|
||||
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||
if (token) {
|
||||
const sess = decryptSession(token);
|
||||
if (sess?.username) return sess.username;
|
||||
}
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (ctx?.username) return ctx.username;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidId(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
|
||||
}
|
||||
function isValidHash(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const username = await resolveUsername();
|
||||
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
|
||||
|
||||
const pluginId = request.nextUrl.searchParams.get('pluginId');
|
||||
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
|
||||
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
const status = await getApprovalStatus(pluginId, bundleHash);
|
||||
return NextResponse.json(status, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approval-status GET', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const username = await resolveUsername();
|
||||
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
|
||||
|
||||
let body: unknown;
|
||||
try { body = await request.json(); } catch { body = null; }
|
||||
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; manifest?: unknown };
|
||||
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
|
||||
const m = (b.manifest ?? {}) as Record<string, unknown>;
|
||||
const manifest: ApprovalEntry['manifest'] = {
|
||||
name: typeof m.name === 'string' ? m.name.slice(0, 200) : undefined,
|
||||
version: typeof m.version === 'string' ? m.version.slice(0, 64) : undefined,
|
||||
author: typeof m.author === 'string' ? m.author.slice(0, 200) : undefined,
|
||||
description: typeof m.description === 'string' ? m.description.slice(0, 500) : undefined,
|
||||
permissions: Array.isArray(m.permissions) ? (m.permissions as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 50) : undefined,
|
||||
httpOrigins: Array.isArray(m.httpOrigins) ? (m.httpOrigins as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
|
||||
apiPostPaths: Array.isArray(m.apiPostPaths) ? (m.apiPostPaths as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
|
||||
};
|
||||
|
||||
const entry = await requestApproval(b.pluginId as string, b.bundleHash as string, manifest, username);
|
||||
return NextResponse.json({ status: entry.status, requestedAt: entry.requestedAt, decidedAt: entry.decidedAt });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approval-status POST', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPublicKeyBase64 } from '@/lib/admin/plugin-signing';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/plugin-signing-pubkey
|
||||
*
|
||||
* Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the
|
||||
* sandboxed plugin loader can verify bundle signatures before evaluation.
|
||||
* Public - every logged-in user needs to fetch it on app boot.
|
||||
*
|
||||
* The response is long-cache-eligible (the key rotates only when an operator
|
||||
* deletes the on-disk PEM), but we keep it `no-store` for simplicity. The
|
||||
* client caches the result in memory for the lifetime of the page.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const publicKey = await getPublicKeyBase64();
|
||||
return NextResponse.json(
|
||||
{ algorithm: 'ed25519', publicKey },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('[plugin-signing-pubkey] load failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Signing key unavailable' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
@@ -10,15 +12,25 @@ import { logger } from '@/lib/logger';
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
const policyForceEnabledIds = new Set(policy.forceEnabledPlugins || []);
|
||||
|
||||
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
|
||||
getPluginRegistry(),
|
||||
getThemeRegistry(),
|
||||
listDevPlugins(),
|
||||
]);
|
||||
|
||||
// Only serve enabled plugins
|
||||
const plugins = pluginRegistry.plugins
|
||||
.filter(p => p.enabled)
|
||||
.map(p => ({
|
||||
// Dev plugins win on id collision so a developer can shadow an installed
|
||||
// plugin without uninstalling it first.
|
||||
const devIds = new Set(devEntries.map(e => e.plugin.id));
|
||||
const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id));
|
||||
|
||||
const plugins = [
|
||||
...devEntries.map(e => ({ ...e.plugin, dev: true })),
|
||||
...installedEnabled.map(p => ({ ...p, dev: false })),
|
||||
].map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
version: p.version,
|
||||
@@ -27,8 +39,24 @@ export async function GET() {
|
||||
type: p.type,
|
||||
permissions: p.permissions,
|
||||
entrypoint: p.entrypoint,
|
||||
forceEnabled: p.forceEnabled || false,
|
||||
settingsSchema: undefined, // Will be read from the bundle's manifest
|
||||
// Policy is the canonical source for force-enable. The per-plugin field
|
||||
// can drift for dev plugins (manifest always loads forceEnabled:false)
|
||||
// and during pending policy saves; OR'ing here unifies the signal so
|
||||
// the client's auto-enable path triggers consistently.
|
||||
forceEnabled: p.forceEnabled || policyForceEnabledIds.has(p.id),
|
||||
// Content hash + updatedAt let clients detect re-uploads even when
|
||||
// the manifest version is unchanged.
|
||||
bundleHash: p.bundleHash,
|
||||
updatedAt: p.updatedAt,
|
||||
// Marks plugins loaded from PLUGIN_DEV_DIR. Surface in UI as a badge.
|
||||
dev: p.dev,
|
||||
// Surface so clients can enforce api.http.fetch origin allowlists.
|
||||
httpOrigins: p.httpOrigins,
|
||||
// Surface so clients can enforce api.http.post path allowlists.
|
||||
apiPostPaths: p.apiPostPaths,
|
||||
// Per-user settings schema, captured from the manifest at upload/load
|
||||
// time so the client can render the settings UI without re-parsing.
|
||||
settingsSchema: p.settingsSchema,
|
||||
}));
|
||||
|
||||
// Only serve enabled themes
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import {
|
||||
getStalwartCredentials,
|
||||
type StalwartCredentials,
|
||||
} from '@/lib/stalwart/credentials';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface ResolvedTarget {
|
||||
authHeader: string;
|
||||
apiUrl: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
// When the SW passes ?accountId=, we need the slot whose JMAP session owns
|
||||
// that account - not just "the first signed-in slot", which is what
|
||||
// getStalwartCredentials() defaults to. Probe each candidate's session in
|
||||
// parallel and return the first match.
|
||||
async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarget | null> {
|
||||
const cookieStore = await cookies();
|
||||
const probes: Promise<ResolvedTarget | null>[] = [];
|
||||
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (!ctx) continue;
|
||||
const serverUrl = ctx.serverUrl.replace(/\/+$/, '');
|
||||
probes.push(
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${serverUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: ctx.authHeader },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const session = (await res.json()) as {
|
||||
apiUrl?: string;
|
||||
primaryAccounts?: Record<string, string>;
|
||||
};
|
||||
const mailAccountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||
if (!session.apiUrl || !mailAccountId) return null;
|
||||
if (mailAccountId !== accountId) return null;
|
||||
return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId: mailAccountId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
const results = await Promise.all(probes);
|
||||
return results.find((r): r is ResolvedTarget => r !== null) ?? null;
|
||||
}
|
||||
|
||||
async function resolveDefaultTarget(creds: StalwartCredentials): Promise<ResolvedTarget | null> {
|
||||
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: creds.authHeader },
|
||||
});
|
||||
if (!sessionRes.ok) return null;
|
||||
const session = (await sessionRes.json()) as {
|
||||
apiUrl?: string;
|
||||
primaryAccounts?: Record<string, string>;
|
||||
};
|
||||
const apiUrl = session.apiUrl;
|
||||
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||
if (!apiUrl || !accountId) return null;
|
||||
return { authHeader: creds.authHeader, apiUrl, accountId };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/push/preview
|
||||
*
|
||||
* Called from the service worker when a Web Push wake-up arrives. Fetches the
|
||||
* latest unread email so the SW can build an enriched system notification
|
||||
* (sender, subject, avatar) without ever exposing JMAP credentials to the
|
||||
* SW context.
|
||||
*
|
||||
* The relay's push payload is intentionally minimal (just a state-change
|
||||
* ping), so this is what makes "From: Alice / Subject: …" appear instead of
|
||||
* a generic "New mail" string.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// SW passes ?accountId=<jmap-account-id> derived from the push payload's
|
||||
// StateChange so multi-account browsers fetch from the right slot. Older
|
||||
// clients (and the manual /api/push/preview probe) omit it and fall back
|
||||
// to the first signed-in slot.
|
||||
const requestedAccountId = request.nextUrl.searchParams.get('accountId');
|
||||
|
||||
let target: ResolvedTarget | null = null;
|
||||
let authHeader: string;
|
||||
if (requestedAccountId) {
|
||||
target = await resolveTargetForAccount(requestedAccountId);
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
authHeader = target.authHeader;
|
||||
} else {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
target = await resolveDefaultTarget(creds);
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
|
||||
}
|
||||
authHeader = creds.authHeader;
|
||||
}
|
||||
|
||||
const { apiUrl, accountId } = target;
|
||||
|
||||
const inboxRes = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||
methodCalls: [
|
||||
[
|
||||
'Mailbox/query',
|
||||
{ accountId, filter: { role: 'inbox' }, limit: 1 },
|
||||
'mb',
|
||||
],
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!inboxRes.ok) {
|
||||
return NextResponse.json({ error: 'JMAP mailbox query failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
const inboxData = (await inboxRes.json()) as {
|
||||
methodResponses: [string, Record<string, unknown>, string][];
|
||||
};
|
||||
|
||||
const inboxBody = inboxData.methodResponses.find(
|
||||
([method]) => method === 'Mailbox/query',
|
||||
)?.[1] as { ids?: string[] } | undefined;
|
||||
|
||||
const inboxId = inboxBody?.ids?.[0];
|
||||
|
||||
if (!inboxId) {
|
||||
return NextResponse.json({
|
||||
email: null,
|
||||
unreadTotal: 0,
|
||||
}, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Pull the most recent unread message from the resolved Inbox mailbox.
|
||||
const requestBody = {
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||
methodCalls: [
|
||||
[
|
||||
'Email/query',
|
||||
{
|
||||
accountId,
|
||||
filter: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ inMailbox: inboxId },
|
||||
{ notKeyword: '$seen' },
|
||||
],
|
||||
},
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit: 1,
|
||||
calculateTotal: true,
|
||||
},
|
||||
'eq',
|
||||
],
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
'#ids': { resultOf: 'eq', name: 'Email/query', path: '/ids' },
|
||||
properties: ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'],
|
||||
},
|
||||
'eg',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
const jmapRes = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
if (!jmapRes.ok) {
|
||||
return NextResponse.json({ error: 'JMAP request failed' }, { status: 502 });
|
||||
}
|
||||
const data = (await jmapRes.json()) as {
|
||||
methodResponses: [string, Record<string, unknown>, string][];
|
||||
};
|
||||
|
||||
type EmailLite = {
|
||||
id: string;
|
||||
threadId: string;
|
||||
from?: { name?: string | null; email?: string }[] | null;
|
||||
subject?: string | null;
|
||||
preview?: string | null;
|
||||
receivedAt?: string | null;
|
||||
};
|
||||
|
||||
let email: EmailLite | null = null;
|
||||
let unreadTotal = 0;
|
||||
for (const [method, body] of data.methodResponses) {
|
||||
if (method === 'Email/query') {
|
||||
unreadTotal = ((body as { total?: number }).total) ?? 0;
|
||||
}
|
||||
if (method === 'Email/get') {
|
||||
const list = (body as { list?: EmailLite[] }).list ?? [];
|
||||
email = list[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
email,
|
||||
unreadTotal,
|
||||
}, {
|
||||
headers: {
|
||||
// SW already gates on its own logic - don't let push events get
|
||||
// cached and served stale.
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// `fetch failed` from undici is too generic to debug - the real reason
|
||||
// (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
|
||||
const err = error as Error & { cause?: { code?: string; message?: string } };
|
||||
logger.error('push preview failed', {
|
||||
error: err?.message ?? 'Unknown error',
|
||||
causeCode: err?.cause?.code,
|
||||
causeMessage: err?.cause?.message,
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user