Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f303478850 | ||
|
|
8bdadc7ba3 | ||
|
|
6b57118add | ||
|
|
ffb645671c | ||
|
|
e63ce25f5f | ||
|
|
d31b30ba4a | ||
|
|
31eff96614 | ||
|
|
2ea8054240 | ||
|
|
5a2e141ed6 | ||
|
|
fa343e0768 | ||
|
|
8969338b2a | ||
|
|
4c720d6855 | ||
|
|
ad175d20e3 | ||
|
|
fb2f0c9158 | ||
|
|
7daa46e73e | ||
|
|
195185dc52 | ||
|
|
8a9dce1a99 | ||
|
|
c690e8eb76 | ||
|
|
9d867cbff6 | ||
|
|
f22699fe20 | ||
|
|
a7db3883aa | ||
|
|
7fcefa53c9 | ||
|
|
bdb76c3d90 | ||
|
|
168b36d419 | ||
|
|
a4f57e7a5c | ||
|
|
6678501501 | ||
|
|
fa0045e01b | ||
|
|
1b816d3185 | ||
|
|
24949e183f | ||
|
|
9207ec563c | ||
|
|
44e0e17203 | ||
|
|
b9c9901643 | ||
|
|
a201c1617b | ||
|
|
d24f402b0c | ||
|
|
5cfc905f10 | ||
|
|
4531cfe47c | ||
|
|
927a3b8b11 | ||
|
|
4ad8396adc | ||
|
|
6aaeb34272 | ||
|
|
ab57966a94 | ||
|
|
18d9b9adf6 | ||
|
|
ed311d79e3 | ||
|
|
457400ceee | ||
|
|
88d87be685 | ||
|
|
5ddb2acfc7 | ||
|
|
5f150f039d | ||
|
|
4f54f768e8 | ||
|
|
c24762c7a3 | ||
|
|
3e85e07363 | ||
|
|
facef97fcc | ||
|
|
89d580b90d | ||
|
|
734155c939 | ||
|
|
790d7084af | ||
|
|
60efb047d8 | ||
|
|
f9052eb23f | ||
|
|
9c5daa3918 | ||
|
|
23870801fc | ||
|
|
ae793551ba | ||
|
|
f19aaf6207 | ||
|
|
d0cc439fd1 | ||
|
|
f42f57b8d0 | ||
|
|
0d4b588fd0 | ||
|
|
6a725dde58 | ||
|
|
6499da6281 | ||
|
|
b2379fb03f | ||
|
|
b29e71124d | ||
|
|
63593e2146 | ||
|
|
10cbe7a637 | ||
|
|
79418f013a | ||
|
|
4024732696 | ||
|
|
ffad3ea78b | ||
|
|
a9b9aeb44d | ||
|
|
3d76fe6d75 | ||
|
|
58cafe28ad | ||
|
|
60f9a3dc4d | ||
|
|
11d9db5580 | ||
|
|
05d3f0b469 | ||
|
|
c3f60448ad | ||
|
|
523711cca3 | ||
|
|
bcb487810a | ||
|
|
7e1d644f7d | ||
|
|
4aac65d7d2 | ||
|
|
14ecae61dc | ||
|
|
c53ff5a30a | ||
|
|
5aad97d64e | ||
|
|
081c865018 | ||
|
|
050f38b1fa | ||
|
|
d657bdfa75 | ||
|
|
bbf724d9e1 | ||
|
|
9d8c6044e3 | ||
|
|
2d17ca71e3 | ||
|
|
d77dd1e3e1 | ||
|
|
9d97b74684 | ||
|
|
c4673acb65 | ||
|
|
1bdc51dcc7 | ||
|
|
6ee0f6a40a | ||
|
|
9ee25c930e | ||
|
|
1b2c70a90b | ||
|
|
cdea876992 | ||
|
|
16557830ae | ||
|
|
8836f9cdca | ||
|
|
26bfe9cb6c | ||
|
|
05eaaad61f | ||
|
|
2e0852b272 | ||
|
|
6173a9ad13 | ||
|
|
e3560d9cb4 | ||
|
|
52326326e2 | ||
|
|
2734fa08b7 | ||
|
|
67a0d622bc | ||
|
|
58968a1cbf | ||
|
|
01c8afbfe8 | ||
|
|
be2e0f2b68 | ||
|
|
aa40c8be26 | ||
|
|
b3d4c9241c | ||
|
|
34dd5122b3 | ||
|
|
dab3606b04 | ||
|
|
0f7638055c | ||
|
|
66fe7fd359 | ||
|
|
1b2ee7da3a | ||
|
|
f6bec519f4 | ||
|
|
7102add194 | ||
|
|
a3d894730b | ||
|
|
79b99ed4f8 | ||
|
|
68214c3e91 | ||
|
|
5da0e2bdf1 | ||
|
|
92ada0460a | ||
|
|
1cce5c3c8a | ||
|
|
40fd7799e9 | ||
|
|
aaa283357e | ||
|
|
066ab1bc32 | ||
|
|
92fb9bf132 | ||
|
|
4c804d5d2b | ||
|
|
63b464641b | ||
|
|
67210c9924 | ||
|
|
8937777bcd | ||
|
|
789d211a71 | ||
|
|
c512352f77 | ||
|
|
c859230862 | ||
|
|
0fb8b81acd | ||
|
|
1eebec292a | ||
|
|
2d983c9853 | ||
|
|
a453f7fa67 | ||
|
|
4c853f176b | ||
|
|
5583d95ecc | ||
|
|
9d1302ef0a | ||
|
|
75b5d31414 | ||
|
|
1e90885f3f | ||
|
|
02577c7502 | ||
|
|
375c0a7339 | ||
|
|
99d5276cfa | ||
|
|
60b2cf5911 | ||
|
|
8d7cd26fa9 | ||
|
|
1ba8e2fd47 | ||
|
|
c7c22bd210 | ||
|
|
6e0c79ca2c | ||
|
|
9140110435 | ||
|
|
e49f7c1a75 | ||
|
|
098127148e | ||
|
|
308adf0101 | ||
|
|
574bd7ecef | ||
|
|
d6b0714b4e | ||
|
|
af8ea8349e | ||
|
|
8eeabfc995 | ||
|
|
ddb636ed73 | ||
|
|
92ff5fe449 | ||
|
|
edcd5aa2a7 | ||
|
|
b5d471e9c8 | ||
|
|
f084b484b7 | ||
|
|
b868ad591d | ||
|
|
42734f16c3 | ||
|
|
b70c727bae | ||
|
|
9b0598b051 | ||
|
|
66cdfe64ce | ||
|
|
f6536573ae | ||
|
|
415c961937 | ||
|
|
ba6b25f3f1 | ||
|
|
a9002763e2 | ||
|
|
b75d064f20 | ||
|
|
a46fb9c8af | ||
|
|
6696636df8 | ||
|
|
37bc88dbad | ||
|
|
7a191cf78b | ||
|
|
733e99f094 | ||
|
|
9b25b6d03e | ||
|
|
05f848ee23 | ||
|
|
4da1b7d8fc | ||
|
|
4ff2bff974 | ||
|
|
47918aab1b | ||
|
|
e48672cf77 | ||
|
|
763abf43b9 | ||
|
|
649261c386 | ||
|
|
e02371d2bf | ||
|
|
4af20b8dc0 | ||
|
|
b5172802bb | ||
|
|
f64306be5e | ||
|
|
05f6d61cea | ||
|
|
0174051f3e | ||
|
|
136686f230 | ||
|
|
54981950b0 | ||
|
|
29a222eef4 | ||
|
|
76b21147e4 | ||
|
|
78bcf8db1b | ||
|
|
39c43e3b3d | ||
|
|
55ee99e811 | ||
|
|
31e7ecceda | ||
|
|
d666bddbc4 | ||
|
|
1cac71fae7 | ||
|
|
79ff95565d | ||
|
|
808328f5a4 | ||
|
|
ddbeadd3c8 | ||
|
|
501d69500d | ||
|
|
e07365b066 | ||
|
|
2baf5e84a5 | ||
|
|
024487bf10 | ||
|
|
97bc26a332 | ||
|
|
55c8d430ca | ||
|
|
ef45140d32 | ||
|
|
0effb97691 | ||
|
|
f7ee204262 | ||
|
|
0c1f182b6b | ||
|
|
13010c158d | ||
|
|
de26e6da2e | ||
|
|
ddb3422852 | ||
|
|
d9a2529261 | ||
|
|
e70224317d | ||
|
|
a9ecf164ab | ||
|
|
0db3cbc959 | ||
|
|
387273288c | ||
|
|
8eff9fdfab | ||
|
|
d915e5fb64 | ||
|
|
5530cfe7fe | ||
|
|
31eee4bab9 | ||
|
|
f04b97d52a | ||
|
|
df272e38ef | ||
|
|
a835af2d71 | ||
|
|
30284859c7 | ||
|
|
64c3d7e384 | ||
|
|
e6d09546b1 | ||
|
|
83a0a1e235 | ||
|
|
7c3c3b5f7b | ||
|
|
8b1b3ad57b | ||
|
|
afefbb8d46 | ||
|
|
4c2d185be4 | ||
|
|
c73940e22a | ||
|
|
09eda86d3f | ||
|
|
0d28d811a8 | ||
|
|
45a485a1fa | ||
|
|
bc202498d5 | ||
|
|
721556e777 | ||
|
|
8c9cf3a66b | ||
|
|
b88026de82 | ||
|
|
6ed8ae5812 | ||
|
|
089583a9ef | ||
|
|
2d834213ee | ||
|
|
439a4dbe8a | ||
|
|
8350bad2a6 | ||
|
|
2d56cc9be9 | ||
|
|
2547c10060 | ||
|
|
01779fa59e | ||
|
|
fc38427ed0 | ||
|
|
3cbfb70860 | ||
|
|
c32b740dac | ||
|
|
a02091a7ad | ||
|
|
bc311adf6a | ||
|
|
8aca1623f4 | ||
|
|
a8be40579e | ||
|
|
705b942800 | ||
|
|
d68b81e6b8 | ||
|
|
616e4d018d | ||
|
|
68e141b787 | ||
|
|
1e6f5e2c8c | ||
|
|
9495b34430 | ||
|
|
65fc489b9c | ||
|
|
bd686c092c | ||
|
|
6cff98ddb8 | ||
|
|
dcc35335f5 | ||
|
|
8a54ae2456 | ||
|
|
e26654a005 | ||
|
|
c1c06c68bb | ||
|
|
74cf642182 | ||
|
|
c5b1731a63 | ||
|
|
40cf164df3 | ||
|
|
ff56245db8 | ||
|
|
9b4de4d152 | ||
|
|
0c9e60db8b | ||
|
|
def8ee89fa | ||
|
|
e7e07a38d7 | ||
|
|
a009e5ae32 | ||
|
|
b141240fa3 | ||
|
|
44896dee3e | ||
|
|
a5c5fa6669 | ||
|
|
95af61c4be | ||
|
|
34e495dde3 | ||
|
|
0b721661e9 | ||
|
|
9fa851a674 | ||
|
|
41f91244d9 | ||
|
|
77514bd054 | ||
|
|
4501b3894b | ||
|
|
2edf2fab89 | ||
|
|
d493bb17dc | ||
|
|
234129397d | ||
|
|
9b3a47f9be | ||
|
|
0fcc932e66 | ||
|
|
0fe8e81dc7 | ||
|
|
267f7257cf | ||
|
|
fb8c9db716 | ||
|
|
2793d4b4af | ||
|
|
96c2ee9e13 | ||
|
|
af115e3245 | ||
|
|
fc79bf4f9b | ||
|
|
b844b88733 | ||
|
|
bcdde9f454 | ||
|
|
bb72ac92ae | ||
|
|
6457b27125 | ||
|
|
ef562bcaad | ||
|
|
9fdbb62205 | ||
|
|
2edbf379e2 | ||
|
|
cdc521b693 | ||
|
|
e7249f8bd3 | ||
|
|
a57492d7c0 | ||
|
|
2b4ff2f3de | ||
|
|
5a3f9faafe | ||
|
|
a74cd32364 | ||
|
|
38c04099e2 | ||
|
|
ae14e09f66 | ||
|
|
ada356e440 | ||
|
|
a886edda6f | ||
|
|
ff97d8bc4c | ||
|
|
b1db100c3a | ||
|
|
19584cfef6 | ||
|
|
8a2ef5a6c5 | ||
|
|
477fc8c885 | ||
|
|
84abfe79dd | ||
|
|
8a92d53125 | ||
|
|
6fe4a98b02 | ||
|
|
3186198fad | ||
|
|
5c933a595f | ||
|
|
828ad4df72 | ||
|
|
f720cb3ef8 | ||
|
|
818a02428b | ||
|
|
6ed30059e5 | ||
|
|
a2cb2b2c86 | ||
|
|
1f7cd61fc7 | ||
|
|
070eeeecee | ||
|
|
de35d1d8e8 | ||
|
|
7c5785e9e8 | ||
|
|
54f4d37595 | ||
|
|
71cd826c6f | ||
|
|
e96b9a72e7 | ||
|
|
ce4ebb3dc2 | ||
|
|
6680b65863 | ||
|
|
df64959e60 | ||
|
|
8641275bb4 | ||
|
|
6e78f2a09e | ||
|
|
d429652f72 |
@@ -5,9 +5,7 @@ node_modules
|
|||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.dev.example
|
!.env.dev.example
|
||||||
.claude/
|
|
||||||
scripts/
|
scripts/
|
||||||
TODO.md
|
TODO.md
|
||||||
CLAUDE.md
|
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
|
|||||||
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
# Bulwark Webmail — Development Configuration
|
# Bulwark Webmail - Development Configuration
|
||||||
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
||||||
# No external mail server required — great for UI development and testing.
|
# No external mail server required - great for UI development and testing.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# cp .env.dev.example .env.local
|
# cp .env.dev.example .env.local
|
||||||
# npm run dev
|
# npm run dev
|
||||||
# Open http://localhost:3000 — log in with any username/password.
|
# Open http://localhost:3000 - log in with any username/password.
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Mock JMAP Server
|
# Mock JMAP Server
|
||||||
|
|||||||
+129
-18
@@ -1,4 +1,4 @@
|
|||||||
# Bulwark Webmail — Production Configuration
|
# Bulwark Webmail - Production Configuration
|
||||||
# Copy this file to .env.local and fill in your values.
|
# Copy this file to .env.local and fill in your values.
|
||||||
# For development with the built-in mock server, see .env.dev.example instead.
|
# For development with the built-in mock server, see .env.dev.example instead.
|
||||||
|
|
||||||
@@ -6,12 +6,19 @@
|
|||||||
# JMAP Server (required)
|
# JMAP Server (required)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# App name displayed in the UI
|
# App name displayed in the UI, browser tab title, and PWA manifest.
|
||||||
APP_NAME=Bulwark Webmail
|
APP_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL of your JMAP-compatible mail server (required)
|
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
||||||
JMAP_SERVER_URL=https://your-jmap-server.com
|
JMAP_SERVER_URL=https://your-jmap-server.com
|
||||||
|
|
||||||
|
# Allow users to specify a custom JMAP server URL on the login form.
|
||||||
|
# When enabled, a "JMAP Server" field appears on the login page.
|
||||||
|
# Users can connect to any JMAP-compatible server.
|
||||||
|
# NOTE: External JMAP servers must include this domain in their CORS
|
||||||
|
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
||||||
|
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stalwart Mail Server Integration
|
# Stalwart Mail Server Integration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -20,11 +27,6 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Set to "false" to disable if using a non-Stalwart JMAP server.
|
# Set to "false" to disable if using a non-Stalwart JMAP server.
|
||||||
# STALWART_FEATURES=true
|
# 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)
|
# OAuth / OpenID Connect (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -41,6 +43,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
|
|
||||||
# OAuth client secret (server-side only, never exposed to the browser)
|
# OAuth client secret (server-side only, never exposed to the browser)
|
||||||
# OAUTH_CLIENT_SECRET=your-client-secret
|
# OAUTH_CLIENT_SECRET=your-client-secret
|
||||||
|
# Alternatively, you can specify the path to a file containing the OAuth client secret.
|
||||||
|
# OAUTH_CLIENT_SECRET_FILE=/oauth-client-secret
|
||||||
|
|
||||||
# OpenID Connect issuer URL for discovery
|
# OpenID Connect issuer URL for discovery
|
||||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||||
@@ -53,6 +57,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Required for both "Remember me" and settings sync features.
|
# Required for both "Remember me" and settings sync features.
|
||||||
# Generate with: openssl rand -base64 32
|
# Generate with: openssl rand -base64 32
|
||||||
# SESSION_SECRET=your-secret-key-here
|
# SESSION_SECRET=your-secret-key-here
|
||||||
|
# Alternatively, you can specify the path to a file containing the session secret.
|
||||||
|
# SESSION_SECRET_FILE=/session-secret
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Settings Sync
|
# Settings Sync
|
||||||
@@ -64,9 +70,48 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# SETTINGS_SYNC_ENABLED=true
|
# SETTINGS_SYNC_ENABLED=true
|
||||||
|
|
||||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||||
# For Docker, mount a persistent volume at this path.
|
# For Docker, the working directory is /app, so the default resolves to
|
||||||
|
# /app/data/settings - mount a persistent volume there (see docker-compose.yml).
|
||||||
# SETTINGS_DATA_DIR=./data/settings
|
# SETTINGS_DATA_DIR=./data/settings
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Admin Dashboard Data
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Directory for admin dashboard state: config overrides, admin password hash,
|
||||||
|
# installed plugins/themes, and audit logs (default: ./data/admin).
|
||||||
|
# For Docker, the default resolves to /app/data/admin - mount a persistent
|
||||||
|
# volume there (see docker-compose.yml).
|
||||||
|
# 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
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Hostname the server binds to (default: 0.0.0.0)
|
||||||
|
# Set to "::" for dual-stack
|
||||||
|
# HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
# Port the server listens on (default: 3000)
|
||||||
|
# PORT=3000
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Logging
|
# Logging
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -78,29 +123,95 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# LOG_LEVEL=info
|
# LOG_LEVEL=info
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Login Page Customization (all optional)
|
# Branding (all optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Custom logo images for the login page (PNG, SVG, etc.)
|
# ---------------------------------------------------------------------------
|
||||||
|
# App identity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Short name for the app, used in contexts where space is limited
|
||||||
|
# (e.g. home screen label on mobile). Defaults to APP_NAME if not set.
|
||||||
|
# APP_SHORT_NAME=Bulwark
|
||||||
|
|
||||||
|
# Description shown in the PWA manifest (displayed by the OS during install).
|
||||||
|
# Defaults to a generic Bulwark description if not set.
|
||||||
|
# APP_DESCRIPTION=Your personal webmail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Icons & favicon
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Custom favicon shown in the browser tab.
|
||||||
|
# Supported formats: SVG (recommended), PNG, ICO.
|
||||||
|
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||||
|
# Defaults to the Bulwark favicon if not set.
|
||||||
|
# FAVICON_URL=/branding/my-favicon.svg
|
||||||
|
|
||||||
|
# Source image used to auto-generate PWA icons (192×192 and 512×512 PNG).
|
||||||
|
# Supported formats: SVG (recommended for best quality) or PNG (≥512×512px recommended).
|
||||||
|
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||||
|
# Falls back to FAVICON_URL if not set, and to the default Bulwark icons if neither is set.
|
||||||
|
# PWA_ICON_URL=/branding/my-icon.svg
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PWA appearance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Color applied to the browser UI chrome when the app is installed as a PWA
|
||||||
|
# (address bar, status bar on Android). Default: #ffffff
|
||||||
|
# PWA_THEME_COLOR=#3b82f6
|
||||||
|
|
||||||
|
# Background color shown on the PWA splash screen while the app is loading.
|
||||||
|
# Should match your app's main background color. Default: #ffffff
|
||||||
|
# PWA_BACKGROUND_COLOR=#ffffff
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Logos shown in the sidebar (main app, after login).
|
||||||
|
# Supported formats: SVG (recommended), PNG, WebP.
|
||||||
|
# Recommended size: min 24×24px, max 128×128px.
|
||||||
# Can be absolute URLs or paths relative to the public/ directory.
|
# Can be absolute URLs or paths relative to the public/ directory.
|
||||||
# Light mode logo (shown on light backgrounds), defaults to Bulwark logo.
|
# If not set, no logo is shown in the sidebar.
|
||||||
|
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
||||||
|
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
||||||
|
|
||||||
|
# Logos shown on the login page.
|
||||||
|
# Supported formats: SVG (recommended), PNG, WebP.
|
||||||
|
# Recommended size: min 32×32px, max 512×512px.
|
||||||
|
# Can be absolute URLs or paths relative to the public/ directory.
|
||||||
|
# Light mode logo (shown on light backgrounds). Defaults to the Bulwark logo.
|
||||||
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
||||||
#
|
# Dark mode logo (shown on dark backgrounds). Defaults to the Bulwark white logo.
|
||||||
# Dark mode logo (shown on dark backgrounds), defaults to Bulwark white logo.
|
|
||||||
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
|
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
|
||||||
|
|
||||||
# Company or organization name displayed above the version on the login page
|
# ---------------------------------------------------------------------------
|
||||||
|
# Login page
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Company name shown above the version number on the login page.
|
||||||
LOGIN_COMPANY_NAME=Bulwark Webmail
|
LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL for the imprint/legal notice link on the login page
|
# URL for the imprint / legal notice link on the login page.
|
||||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
|
|
||||||
# URL for the privacy policy link on the login page
|
# URL for the privacy policy link on the login page.
|
||||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||||
|
|
||||||
# URL for the company website link on the login page
|
# URL for the company website link on the login page.
|
||||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Extension Directory / Marketplace
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# URL of the BulwarkMail extension directory for the admin marketplace.
|
||||||
|
# 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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Legacy Build-time Variables (still supported as fallback)
|
# Legacy Build-time Variables (still supported as fallback)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Bulwark Webmail – Funding configuration
|
||||||
|
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||||
|
|
||||||
|
github: [bulwarkmail]
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
name: Publish Docker Image on Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
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 }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=semver,pattern=v{{version}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern=v{{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern=v{{major}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
@@ -2,7 +2,9 @@ name: Publish Docker Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
paths:
|
paths:
|
||||||
- "Dockerfile"
|
- "Dockerfile"
|
||||||
- ".dockerignore"
|
- ".dockerignore"
|
||||||
@@ -17,12 +19,34 @@ on:
|
|||||||
- "package.json"
|
- "package.json"
|
||||||
- "package-lock.json"
|
- "package-lock.json"
|
||||||
- ".github/workflows/docker-publish.yml"
|
- ".github/workflows/docker-publish.yml"
|
||||||
tags: ["v*.*.*"]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
prepare:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
image_name: ${{ steps.set.outputs.image_name }}
|
||||||
|
steps:
|
||||||
|
- name: Set image name
|
||||||
|
id: set
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||||
|
echo "image_name=ghcr.io/${{ github.repository }}-beta" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "image_name=ghcr.io/${{ github.repository }}-${{ github.ref_name }}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: prepare
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -31,9 +55,6 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -48,21 +69,73 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: ${{ needs.prepare.outputs.image_name }}
|
||||||
ghcr.io/${{ github.repository }}
|
|
||||||
tags: |
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=sha,prefix=
|
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||||
cache-to: type=gha,mode=max
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [prepare, build]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ needs.prepare.outputs.image_name }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=sha
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ needs.prepare.outputs.image_name }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ needs.prepare.outputs.image_name }}:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: Publish Standalone Tarball on Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
arch: amd64
|
||||||
|
- os: ubuntu-24.04-arm
|
||||||
|
arch: arm64
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build standalone
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Package tarball
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
ARCH: ${{ matrix.arch }}
|
||||||
|
run: |
|
||||||
|
VERSION="${REF_NAME#v}"
|
||||||
|
TARBALL="bulwark-standalone-${VERSION}-linux-${ARCH}.tar.gz"
|
||||||
|
|
||||||
|
mkdir -p bulwark-standalone
|
||||||
|
cp -r .next/standalone/. bulwark-standalone/
|
||||||
|
cp -r .next/static bulwark-standalone/.next/static
|
||||||
|
cp -r public bulwark-standalone/public
|
||||||
|
|
||||||
|
tar -czf "$TARBALL" bulwark-standalone/
|
||||||
|
echo "TARBALL=$TARBALL" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Upload release asset
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||||
|
run: gh release upload "$TAG_NAME" "$TARBALL" --clobber
|
||||||
|
|
||||||
|
- name: Upload artifact (workflow_dispatch)
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: standalone-${{ matrix.arch }}
|
||||||
|
path: ${{ env.TARBALL }}
|
||||||
|
retention-days: 7
|
||||||
+3
-3
@@ -42,11 +42,11 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# claude code
|
|
||||||
.claude/
|
|
||||||
|
|
||||||
# settings sync data
|
# settings sync data
|
||||||
/data/
|
/data/
|
||||||
|
|
||||||
# Local private data
|
# Local private data
|
||||||
/local-data/
|
/local-data/
|
||||||
|
|
||||||
|
# Sibling repos
|
||||||
|
/repos/
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Check for AI attribution in commit message
|
|
||||||
if grep -qi "co-authored-by.*claude\|co-authored-by.*anthropic\|claude code\|claude sonnet\|claude opus" "$1"; then
|
|
||||||
echo "❌ ERROR: Commit message contains AI attribution (Claude/Anthropic)"
|
|
||||||
echo " This violates project policy in CLAUDE.md"
|
|
||||||
echo " Remove 'Co-Authored-By: Claude' and similar references"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
+475
-2
@@ -1,5 +1,478 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
|
**One-time**
|
||||||
|
|
||||||
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
- [@boris22100](https://github.com/boris22100)
|
||||||
|
|
||||||
|
**Monthly**
|
||||||
|
|
||||||
|
- [@pr0ton11](https://github.com/pr0ton11)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Email**: Add unified mailbox across accounts and sidebar icons toggle
|
||||||
|
- **Email**: Enhance email deletion and spam handling with improved parameterization
|
||||||
|
- **Sieve**: Enhance external rule handling in parser and store (#201)
|
||||||
|
- **Plugins**: Add i18n API, render hooks, and new intercept hooks to plugin system
|
||||||
|
- **PWA**: Dynamic PWA manifest with configurable name, description, and icons
|
||||||
|
- **PWA**: Show app name and logo in install prompt
|
||||||
|
- **i18n**: Add Ukrainian language with flags and missing translation keys
|
||||||
|
- **i18n**: Configurable locale prefix via `NEXT_PUBLIC_LOCALE_PREFIX`
|
||||||
|
- **API**: Add `apiFetch` helper for mount-prefix-aware API calls
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Calendar**: Send iMIP invitation emails when creating or updating calendar events (#192)
|
||||||
|
- **Calendar**: RFC 5545/6047 compliance for outgoing iMIP calendar emails
|
||||||
|
- **Calendar**: Add `calendarAddress` and `replyTo` to participants for Stalwart compatibility (#189, #192)
|
||||||
|
- **Calendar**: Improve CalDAV task detection for external clients like Thunderbird (#84)
|
||||||
|
- **Email**: Hide ICS attachments from attachment list when invitation banner is shown
|
||||||
|
- **Email**: Send before storing in Sent via `onSuccessUpdateEmail` (#188)
|
||||||
|
- **Email**: Standardize tag naming and fix unknown keyword display (#184, #185)
|
||||||
|
- **i18n**: Skip intl middleware for paths already containing a locale prefix
|
||||||
|
- **Docs**: Document PWA and branding env vars in `.env.example`
|
||||||
|
- **Docs**: Use `company` consistently in `.env.example` branding comments
|
||||||
|
|
||||||
|
## 1.4.13 (2026-04-12)
|
||||||
|
|
||||||
|
Thank you for your donations:
|
||||||
|
|
||||||
|
**One-time**
|
||||||
|
|
||||||
|
- [@boris22100](https://github.com/boris22100)
|
||||||
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
|
||||||
|
**Monthly**
|
||||||
|
|
||||||
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Contacts**: Store trusted senders in a dedicated JMAP address book (#176)
|
||||||
|
- **Email**: Warn on send when attachment keyword found but no file attached (#172)
|
||||||
|
- **Email**: Enable keyword reordering (#174) and multi-tag support per email (#173)
|
||||||
|
- **PWA**: Add "don't remind me again" option to install prompt
|
||||||
|
- **Auth**: Add `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` environment variable support
|
||||||
|
- **Plugins**: Add `onAvatarResolve` plugin hook
|
||||||
|
- **Docker**: Publish main and dev branches as separate GHCR packages
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Email**: Style links in plain text emails
|
||||||
|
- **Email**: Seed list history entry when app initializes on an email view
|
||||||
|
- **Email**: Remount composer on draft edit and preserve identity (#60)
|
||||||
|
- **Contacts**: Display contact names stored in `name.full` (#179)
|
||||||
|
- **Contacts**: Fix category dropdown blocking Save button in contact form (#177)
|
||||||
|
- **Contacts**: Resolve TS error from optional `name.components` in vCard parser
|
||||||
|
- **Search**: Search all folders when filtering emails by tag (#175)
|
||||||
|
- **Auth**: Include mount prefix in SSO redirect URI when app is served under a subpath
|
||||||
|
- **PWA**: Correct PWA icons with proper sizing, transparency, and dark/light mode support
|
||||||
|
|
||||||
|
## 1.4.12 (2026-04-09)
|
||||||
|
|
||||||
|
Thank you for your donations:
|
||||||
|
|
||||||
|
**One-time**
|
||||||
|
|
||||||
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
|
||||||
|
**Monthly**
|
||||||
|
|
||||||
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **PWA**: Add PWA support with service worker and install prompt
|
||||||
|
- **Calendar**: Add birthday calendar feature with settings and localization
|
||||||
|
- **Calendar**: Clamp February 29 birthdays in non-leap years
|
||||||
|
- **Identity**: Add automatic identity synchronization (#167)
|
||||||
|
- **Plugins**: Disable plugins by default and require admin approval
|
||||||
|
- **Plugins**: Replace auth header exposure with a secure HTTP proxy API for plugins
|
||||||
|
- **Auth**: Add configurable OAuth scopes and cookie security via environment variables
|
||||||
|
- **Email**: Sync mail view to browser history for back/forward navigation
|
||||||
|
- **Contacts**: Add ability to rename address books (#152)
|
||||||
|
- **UI**: Add version badge in settings
|
||||||
|
- **i18n**: Add Latvian (lv) locale support
|
||||||
|
- **i18n**: Add Polish language support
|
||||||
|
- **i18n**: Add Korean language support
|
||||||
|
- **i18n**: Add Simplified Chinese (zh_CN) locale support
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Email**: Show recipient instead of sender in Sent and Drafts folder lists
|
||||||
|
- **Email**: Embed dropped images as data URLs and prevent duplicate attachments (#163)
|
||||||
|
- **Email**: Fix logic for marking email as read in EmailViewer
|
||||||
|
- **Email**: Fix archive action passing MouseEvent as argument
|
||||||
|
- **Mailbox**: Preserve search filters on push-triggered mailbox refresh (#164)
|
||||||
|
- **Mailbox**: Align shared account folders with primary folders (#151)
|
||||||
|
- **Mailbox**: Fetch mailboxes on mount in FolderSettings when store is empty
|
||||||
|
- **Mailbox**: Improve mailbox deletion error handling
|
||||||
|
- **Calendar**: Improve calendar event retrieval by batching requests to avoid server limits (#141)
|
||||||
|
- **Calendar**: Compute per-occurrence UTC start/end in recurrence expansion (#116)
|
||||||
|
- **Calendar**: Guard against undefined trigger in calendar event alert popover (#143)
|
||||||
|
- **Files**: Stream WebDAV PUT uploads to avoid buffering in memory (#162)
|
||||||
|
- **Files**: Prune recent files against server nodes on refresh (#146)
|
||||||
|
- **Files**: Fix file deletion logic to update recent files and handle errors (#146)
|
||||||
|
- **Files**: Extend file drop zone to fill remaining viewport height
|
||||||
|
- **Files**: Fallback to application/octet-stream for long MIME types
|
||||||
|
- **Security**: Replace unguarded crypto.randomUUID() with safe generateUUID() utility
|
||||||
|
- **Security**: Validate plugin HTTP post URL against origin with regression tests
|
||||||
|
- **Security**: Allow blob images in CSP for inline drag-and-drop (#163)
|
||||||
|
- **Auth**: Resolve settings sync identity mismatch for OAuth/SSO sessions (#127)
|
||||||
|
- **Contacts**: Fix address book ID namespacing for shared contacts in create and update operations (#133)
|
||||||
|
- **UI**: Fix focused mode expanding beyond screen bounds (#156)
|
||||||
|
- **API**: Handle 403 on principal fetch without console error
|
||||||
|
- **API**: Enhance error handling in Stalwart API responses
|
||||||
|
|
||||||
|
## 1.4.11 (2026-03-31)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Logging**: Add logging categories for better log management
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Harden security with CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, and PDF iframe sandbox
|
||||||
|
- **Security**: Harden proxy authentication and SSRF defenses
|
||||||
|
- **Security**: Block plugins with dangerous JS patterns and enforce strict session secret length validation
|
||||||
|
- **S/MIME**: Add self-signed certificate detection and update status messages for S/MIME signatures
|
||||||
|
- **Email**: Auto-focus input fields in email composer for improved user experience (#126)
|
||||||
|
- **Mailbox**: Prevent orphaning of nested mailboxes by restricting deduplication to root-level folders
|
||||||
|
- **JMAP**: Strip server-immutable fields from updates before sending to JMAP (#128)
|
||||||
|
- **Files**: Update file feature disabled messages and add stability warnings
|
||||||
|
- **i18n**: Add missing translation keys to all non-English locales
|
||||||
|
|
||||||
|
## 1.4.10 (2026-03-31)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: Add plugin configuration UI with schema-driven admin config page, calendar event action slot, and Jitsi Meet plugin
|
||||||
|
- **Calendar**: Implement client-side recurrence expansion for calendar events
|
||||||
|
- **Calendar**: Add iCal subscription editing and batch event import
|
||||||
|
- **Calendar**: Add hover preview settings and functionality
|
||||||
|
- **Calendar**: Add virtual location input for calendar events (#121)
|
||||||
|
- **Email**: Add reply-to addresses support in email composer
|
||||||
|
- **Email**: Add mail layout settings and update email list components
|
||||||
|
- **Email**: Add auto-select reply identity feature with settings and localization
|
||||||
|
- **Email**: Enhance compose functionality with button integration and translations
|
||||||
|
- **Filters**: Preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts
|
||||||
|
- **Filters**: Skip server-managed vacation script in Sieve script handling
|
||||||
|
- **Settings**: Add support for custom JMAP server endpoints in login and settings
|
||||||
|
- **Settings**: Add folder expansion state management and settings navigation
|
||||||
|
- **UI**: Add options to hide account switcher and show account avatars on navigation rail
|
||||||
|
- **i18n**: Add JMAP server endpoint labels and hints in multiple languages
|
||||||
|
- **i18n**: Add missing translation keys to all non-English locales
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Patch critical auth bypass and credential leak vulnerabilities
|
||||||
|
- **Security**: Support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys and add diagnostic logging (#35)
|
||||||
|
- **Security**: Account isolation, auto-import signer certs, and no-key error handling (#35)
|
||||||
|
- **Calendar**: Fix JSCalendar 2.0 recurrenceRule single-object compatibility (#116)
|
||||||
|
- **Calendar**: Enhance calendar event handling to distinguish between events and tasks
|
||||||
|
- **Calendar**: Link existing events to target calendar during iCal import instead of skipping (#113)
|
||||||
|
- **Calendar**: Deduplicate UIDs during iCal import to prevent mass failures (#113)
|
||||||
|
- **Calendar**: Fix events disappearing after iCal import/subscription refresh
|
||||||
|
- **Calendar**: Enhance calendar event handling with full-day detection and layout adjustments
|
||||||
|
- **Calendar**: Use UTC timestamps for timed event rendering
|
||||||
|
- **Calendar**: Work around Stalwart not returning Task objects via CalendarEvent/query
|
||||||
|
- **Email**: Enhance email loading and deduplication logic in email store (#119)
|
||||||
|
- **Email**: Ensure draft editing function is called correctly in EmailViewer component (#60)
|
||||||
|
- **Email**: Match hover action background to selected row state
|
||||||
|
- **Email**: Align tag counts with mailbox folder counts in sidebar
|
||||||
|
- **Auth**: Handle 2FA/TOTP session expiry with basic auth (#117)
|
||||||
|
- **Mailbox**: Improve mailbox tree logic and enhance mailbox handling with logging (#118)
|
||||||
|
- **UI**: Improve dark mode handling for media elements and background images
|
||||||
|
- **UI**: Adjust account list spacing and remove push connection indicator
|
||||||
|
- **UI**: Fix nested button in theme card
|
||||||
|
|
||||||
|
## 1.4.9 (2026-03-27)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Admin**: Add Stalwart admin authentication, sidebar access, and a reorganized dashboard with dedicated policy sections
|
||||||
|
- **Plugins**: Add plugin/theme admin dashboard, harness tooling, forced enable or disable controls, managed policy enforcement, and a resizable detail sidebar
|
||||||
|
- **Filters**: Add vacation responder management with Sieve generation and parsing, UI integration, and improved sync preservation
|
||||||
|
- **Email**: Add plain text only composer mode, optional conversation threading disable, configurable hover action placement, and OAuth app password support
|
||||||
|
- **UI**: Add drag-and-drop customization for sidebar apps
|
||||||
|
- **Files**: Use dynamic server-configured maximum upload sizes
|
||||||
|
- **i18n**: Add Russian locale support and complete missing translation strings for recent task features
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Calendar**: Improve date parsing and event normalization, prevent calendar page re-render loops, ensure unique ICal subscription IDs, and create all-day events with correct JSCalendar midnight handling
|
||||||
|
- **Email**: Respect the configured mark-as-read delay in EmailViewer and fetch full email content when needed while editing drafts (#60, #95)
|
||||||
|
- **Auth**: Improve network error handling, add JMAP rate limiting handling, and enhance settings retrieval and persistence diagnostics (#100, #104)
|
||||||
|
- **UI**: Improve mobile layout behavior on contacts and calendar pages (#103)
|
||||||
|
- **Themes**: Repair theme ZIP bundle handling and enforce admin theme locks correctly
|
||||||
|
- **Code Quality**: Resolve outstanding ESLint warnings across the codebase
|
||||||
|
|
||||||
|
## 1.4.8 (2026-03-23)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Email**: Add support for marking emails as answered or forwarded and display status icons in email list and thread views
|
||||||
|
- **Email**: Enhance identity selection by supporting sub-addressing (plus addressing) in email composer
|
||||||
|
- **Settings**: Add notification settings with sound picker, preview playback, and configurable alert sounds
|
||||||
|
- **Settings**: Add default mail program settings with localization support across all locales
|
||||||
|
- **Auth**: Implement path prefix handling for OAuth callbacks and login redirects, enabling reverse proxy deployments
|
||||||
|
- **Validation**: Add all multi-part TLDs for domain validation in favicon API (#81)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Calendar**: Fix bugs in duration parsing, RFC compliance, and event handling across calendar components
|
||||||
|
- **Calendar**: Detect tasks created by external CalDAV clients such as Thunderbird
|
||||||
|
- **Settings**: Enhance account settings with username and authentication method display (#90)
|
||||||
|
|
||||||
|
## 1.4.7 (2026-03-21)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Calendar**: Add task management features with task creation, editing, and status tracking
|
||||||
|
- **Calendar**: Add option to show week numbers in mini-calendar
|
||||||
|
- **Email**: Add resizable image component and rich text editor with image upload support
|
||||||
|
- **Files**: Support uploading folders via drag-and-drop and toolbar button
|
||||||
|
- **Filters**: Add expanded visual view for filter rules
|
||||||
|
- **Auth**: Add non-interactive SSO login flow for embedded/iframe deployments (#69)
|
||||||
|
- **DevOps**: Add separate Docker build workflow for releases and dev branch images
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Calendar**: Handle updates and deletions for synthetic JMAP IDs in calendar events with fallback to destroy and recreate
|
||||||
|
- **Security**: Extend CryptoEngine to support legacy algorithms and integrate with LinerEngine for decryption
|
||||||
|
- **Auth**: Refactor logout to use synchronous flow with full page redirect
|
||||||
|
- **Email**: Update iframe sandbox attributes to allow popups to escape sandbox
|
||||||
|
- **i18n**: Add missing translation keys across all locales
|
||||||
|
- **Docker**: Update .env.example to clarify Docker volume mounting for settings data directory
|
||||||
|
|
||||||
|
## 1.4.6 (2026-03-21)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Demo**: Add full demo mode with fixture data for emails, calendars, contacts, files, filters, identities, mailboxes, and vacation responses
|
||||||
|
- **Demo**: Implement JMAP client interface abstraction to support demo and live backends
|
||||||
|
- **Contacts**: Add no-category filter, drag-and-drop to category, and category combo box in contact form
|
||||||
|
- **Email**: Add hover actions for emails with configurable quick-action buttons
|
||||||
|
- **Settings**: Implement keyword migration functionality for upgrading legacy email tags
|
||||||
|
- **Security**: Enhance S/MIME certificate extraction and add legacy PBE (password-based encryption) support
|
||||||
|
- **Tour**: Add interactive guided tour overlay for new user onboarding
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Settings**: Add missing `showTimeInMonthView` and `showOnMobile` type definitions to settings store
|
||||||
|
- **UI**: Adjust padding and size of sidebar buttons for improved layout
|
||||||
|
|
||||||
|
## 1.4.5 (2026-03-20)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Calendar**: Add prev/next navigation buttons and date label to desktop calendar toolbar
|
||||||
|
- **Calendar**: Add pending event preview functionality to calendar views and event modal
|
||||||
|
- **Calendar**: Add setting to show event start time in month view
|
||||||
|
- **Contacts**: Implement pagination for fetching contacts with maxObjectsInGet capability
|
||||||
|
- **Email**: Add attachment position setting in email settings
|
||||||
|
- **Layout**: Add mobile visibility toggle for sidebar apps
|
||||||
|
- **Error**: Add NotFound component to handle 404 errors and redirect unauthenticated users
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Auth**: Enhance account switching logic and clear stores on account change
|
||||||
|
- **Auth**: Improve account restoration logic and handle stale accounts
|
||||||
|
- **Auth**: Improve draft handling in email composer and enhance session cookie verification
|
||||||
|
- **Calendar**: Expand recurring events in CalendarEvent/query so individual occurrences are returned (#65)
|
||||||
|
- **Calendar**: Validate event start field when fetching calendar events
|
||||||
|
- **Calendar**: Auto-scroll agenda view to today's events and include today's date in groups
|
||||||
|
- **Calendar**: Correct JSX syntax in CalendarToolbar component
|
||||||
|
- **Dependencies**: Update flatted to 3.4.2
|
||||||
|
- **DevOps**: Use native ARM runners instead of QEMU for Docker builds
|
||||||
|
- **DevOps**: Enhance health check with detailed memory diagnostics and stable liveness probe
|
||||||
|
|
||||||
|
## 1.4.4 (2026-03-19)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Calendar**: Implement CalDAV discovery API with automatic calendar home resolution for multi-account setups
|
||||||
|
- **Calendar**: Enhance calendar management settings with mailbox role reassignment controls
|
||||||
|
- **Email**: Add signature rendering utilities with HTML-to-text conversion and sanitization
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Auth**: Fix account session handling to update existing accounts instead of duplicating entries
|
||||||
|
- **Auth**: Fix logout redirects and unauthenticated home page rendering
|
||||||
|
- **Calendar**: Fix duplicate calendar edits and prevent double-save submissions in event modal
|
||||||
|
- **Calendar**: Remove stale calendar ID references in favor of CalDAV-discovered IDs
|
||||||
|
- **Contacts**: Improve RFC 9553 compliance for contact birthdays and address formatting
|
||||||
|
- **Email**: Fix email signature rendering for identity signatures
|
||||||
|
- **Folders**: Improve mailbox role management by clearing roles from all mailboxes before reassigning
|
||||||
|
|
||||||
|
## 1.4.3 (2026-03-19)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Auth**: Implement multi-account support with up to 5 simultaneous accounts and instant switching
|
||||||
|
- **Auth**: Add account switcher component with connection status, default account selection, and per-account logout
|
||||||
|
- **Auth**: Support multi-account OAuth and basic auth with per-account session persistence
|
||||||
|
- **Contacts**: Enhance contacts sidebar with collapsible sections, bulk operations, and address book grouping
|
||||||
|
- **Contacts**: Add contact import functionality and keyword filtering
|
||||||
|
- **Settings**: Add per-account encrypted settings storage with server-side sync support
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **UI**: Adjust popover alignment in sub-address helper component
|
||||||
|
- **Settings**: Improve error logging in settings sync functionality
|
||||||
|
|
||||||
|
## 1.4.2 (2026-03-19)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Calendar**: Add task list view for calendar tasks with task details and management
|
||||||
|
- **Calendar**: Add shared calendar grouping with visual separation in sidebar
|
||||||
|
- **Calendar**: Support double-click to create events and improve modal date handling
|
||||||
|
- **Contacts**: Add address book directories with drag-and-drop and editor picker
|
||||||
|
- **Email**: Add email attachment support in sendEmail functionality
|
||||||
|
- **Email**: Implement draft editing functionality across email components
|
||||||
|
- **Email**: Implement unwrapping of embedded message/rfc822 attachments with enhanced HTML body validation
|
||||||
|
- **Email**: Add email export/import localization keys for multiple languages
|
||||||
|
- **Contacts**: Update gender handling to use speakToAs structure
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Email**: Resolve default sender to canonical identity on local-part login
|
||||||
|
- **Email**: Refactor overflow handling in EmailViewer to use hidden priorities and layout effects
|
||||||
|
- **Email**: Remove debugMode usage from EmailViewer component
|
||||||
|
- **Calendar**: Enhance IMIP invitation and cancellation handling for calendar events
|
||||||
|
- **Calendar**: Add time-based sorting for events in buildWeekSegments function
|
||||||
|
- **Dependencies**: Update dompurify to 3.3.3 and elliptic to 6.6.1, add undici override
|
||||||
|
|
||||||
|
## 1.4.1 (2026-03-18)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Security**: Add S/MIME certificate management with identity bindings, signer auto-import, unlock controls, and compose/viewer sign, encrypt, decrypt, and verification flows
|
||||||
|
- **Email**: Add TNEF (`winmail.dat`) parsing to extract message bodies and attachments from Outlook rich-text emails
|
||||||
|
- **Email**: Add archive organization modes for archiving directly or into year/month subfolders
|
||||||
|
- **Email**: Add an "Always Show Emails in Light Mode" preference to avoid dark-mode conversion issues
|
||||||
|
- **Email**: Apply the 12-hour or 24-hour time format preference consistently across calendar and email surfaces
|
||||||
|
- **Identity**: Add identity refresh behavior in the identity manager so server-side changes stay in sync after edits
|
||||||
|
- **UI**: Add configurable sidebar apps with custom icons plus inline or new-tab launch modes
|
||||||
|
- **Branding**: Add runtime branding options for custom favicon, sidebar logos, and login logos
|
||||||
|
- **Deployment**: Add configurable server listen address support via `HOSTNAME`, including IPv6 and dual-stack guidance
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Calendar**: Improve all-day event handling
|
||||||
|
- **Calendar**: Validate and default persisted calendar view mode values
|
||||||
|
- **UI**: Use configured app names more consistently in metadata and login branding surfaces
|
||||||
|
- **Docker**: Correct `HOSTNAME` formatting in the Docker Compose example
|
||||||
|
- **Metadata**: Correct package author and container vendor metadata
|
||||||
|
|
||||||
## 1.3.0 (2026-03-16)
|
## 1.3.0 (2026-03-16)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -107,7 +580,7 @@
|
|||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
||||||
- **Move to folder**: Fix emails not actually moving on the server — JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
- **Move to folder**: Fix emails not actually moving on the server - JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
||||||
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
|
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
|
||||||
|
|
||||||
## 1.1.1 (2026-02-28)
|
## 1.1.1 (2026-02-28)
|
||||||
@@ -117,7 +590,7 @@
|
|||||||
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
||||||
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
||||||
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
||||||
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) — upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) - upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
||||||
|
|
||||||
## 1.1.0 (2026-02-28)
|
## 1.1.0 (2026-02-28)
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -1,9 +1,9 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" />
|
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" />
|
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||||
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" />
|
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -12,6 +12,17 @@
|
|||||||
|
|
||||||
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
|
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
* **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 Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Development Setup
|
### Development Setup
|
||||||
@@ -195,7 +206,7 @@ webmail/
|
|||||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||||
- **Sanitize user input** and email content
|
- **Sanitize user input** and email content
|
||||||
- **Block external content** by default for privacy
|
- **Block external content** by default for privacy
|
||||||
- Report security vulnerabilities privately
|
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,7 @@ LABEL org.opencontainers.image.description="Modern webmail client built with Nex
|
|||||||
LABEL org.opencontainers.image.source="https://github.com/bulwarkmail/webmail"
|
LABEL org.opencontainers.image.source="https://github.com/bulwarkmail/webmail"
|
||||||
LABEL org.opencontainers.image.url="https://github.com/bulwarkmail/webmail"
|
LABEL org.opencontainers.image.url="https://github.com/bulwarkmail/webmail"
|
||||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-only"
|
LABEL org.opencontainers.image.licenses="AGPL-3.0-only"
|
||||||
LABEL org.opencontainers.image.vendor="root.cloud"
|
LABEL org.opencontainers.image.vendor="rbm.systems"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -26,6 +26,7 @@ RUN apk upgrade --no-cache && \
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
# Features
|
||||||
|
|
||||||
|
## Mail
|
||||||
|
|
||||||
|
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding)
|
||||||
|
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||||
|
- Unified mailbox view across all connected accounts
|
||||||
|
- Draft auto-save with identity preservation
|
||||||
|
- Attachment upload, download, and inline preview; 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
|
||||||
|
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
||||||
|
- Plain-text composer mode and Reply-To support
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- Three-pane layout 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
|
||||||
|
|
||||||
|
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 & Multi-Account
|
||||||
|
|
||||||
|
- Up to 5 simultaneous accounts with instant switching and per-account session persistence
|
||||||
|
- Account switcher with connection status and default account selection
|
||||||
|
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
||||||
|
- Sub-addressing (`user+tag@domain.com`) with contextual tag suggestions
|
||||||
|
- Shared folders across accounts
|
||||||
|
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||||
|
|
||||||
|
## Admin & Extensibility
|
||||||
|
|
||||||
|
- Stalwart admin dashboard with dedicated policy sections
|
||||||
|
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement
|
||||||
|
- 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`)
|
||||||
|
- Bundled plugins including Jitsi Meet calendar integration
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
- Progressive Web App with service worker, install prompt, and dynamic manifest
|
||||||
|
- Automatic update check with server-side logging of new releases
|
||||||
|
- Structured logging (`text` or `json`) with category-based levels
|
||||||
|
- Release (`main`) and development (`dev`) Docker images on GHCR
|
||||||
|
- Demo mode with fixture data – no mail server required
|
||||||
@@ -8,12 +8,12 @@
|
|||||||
|
|
||||||
# Bulwark Webmail
|
# Bulwark Webmail
|
||||||
|
|
||||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/>
|
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
||||||
Built with Next.js and the JMAP protocol.
|
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](CHANGELOG.md)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](CHANGELOG.md)
|
||||||
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,180 +21,58 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
## Screenshots
|
## 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>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
|
||||||
|
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
|
||||||
<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>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<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>
|
||||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
</tr>
|
||||||
|
<tr>
|
||||||
**Contacts** — Contact management with groups and vCard support
|
<td><img src="screenshots/theme.png" alt="Themes" /></td>
|
||||||
|
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
|
||||||
</td>
|
</tr>
|
||||||
<td width="50%">
|
<tr>
|
||||||
|
<td><sub><b>Themes</b> – bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
|
||||||
<img src="screenshots/files.png" width="100%" alt="File browser">
|
<td><sub><b>Plugins</b> – extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
|
||||||
|
</tr>
|
||||||
**Files** — Cloud file browser with upload, preview, and folder navigation
|
<tr>
|
||||||
|
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
|
||||||
</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>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<details>
|
> **Anonymous telemetry is on by default** since 1.5.3. Each instance sends a daily heartbeat (version, platform, bucketed account counts, feature toggles - no message data, no PII). Disable from **Admin → Telemetry**, by setting `BULWARK_TELEMETRY=off`, or by clearing the endpoint. Full schema: [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
|
||||||
<summary>More screenshots</summary>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td width="50%">
|
|
||||||
|
|
||||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox — light mode">
|
## Overview
|
||||||
|
|
||||||
**Light mode** — Full theme support with intelligent color transformation
|
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:
|
||||||
|
|
||||||
</td>
|
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
||||||
<td width="50%">
|
- **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
|
||||||
|
|
||||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
|
||||||
|
|
||||||
**Settings** — Appearance, identities, filters, templates, and more
|
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||||
|
|
||||||
</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
|
|
||||||
- **Threading** — Gmail-style inline expansion with thread navigation
|
|
||||||
- **Draft auto-save** with discard confirmation
|
|
||||||
- **Attachments** — upload, download, and inline preview
|
|
||||||
- **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
|
|
||||||
- **Print** emails directly from the viewer
|
|
||||||
- **Color tags/labels** and star/unstar
|
|
||||||
- **Virtual scrolling** for large mailboxes
|
|
||||||
- **Quick reply** from the viewer
|
|
||||||
- **Sender avatars** — favicon-based with negative caching for performance
|
|
||||||
- **Recipient popover** for quick contact interaction
|
|
||||||
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
|
|
||||||
- **Tag counts** — unread and total counts displayed in sidebar
|
|
||||||
|
|
||||||
### Calendar
|
|
||||||
|
|
||||||
- **Month, week, day, and agenda views** with mini-calendar sidebar
|
|
||||||
- **Event hover preview** popover with details
|
|
||||||
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
|
|
||||||
- **Recurring events** with edit/delete scope (this / this and following / all)
|
|
||||||
- **Participant scheduling** — iTIP invitations, organizer/attendee UI, RSVP
|
|
||||||
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
|
|
||||||
- **iCalendar import** with preview and bulk create
|
|
||||||
- **Notifications** with configurable sound and alert persistence
|
|
||||||
- **Real-time sync** via JMAP push
|
|
||||||
|
|
||||||
### Contacts
|
|
||||||
|
|
||||||
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
|
|
||||||
- **Contact groups** with group expansion and member management
|
|
||||||
- **vCard import/export** (RFC 6350) with duplicate detection
|
|
||||||
- **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…)
|
|
||||||
- **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 and folder upload support
|
|
||||||
- **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
|
|
||||||
- **SPF/DKIM/DMARC** status indicators
|
|
||||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode
|
|
||||||
- **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** — CSP with per-request nonce, X-Frame-Options, Referrer-Policy
|
|
||||||
- **Newsletter unsubscribe** (RFC 2369)
|
|
||||||
|
|
||||||
### Interface
|
|
||||||
|
|
||||||
- **Three-pane layout** — sidebar, email list, viewer with resizable columns
|
|
||||||
- **Dark and light themes** with intelligent email color transformation
|
|
||||||
- **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
|
|
||||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
|
||||||
- **Customizable toolbar** position and login page branding
|
|
||||||
- **Configurable logo** with light/dark mode variants
|
|
||||||
- **Settings sync** — preferences synchronized with the server (encrypted)
|
|
||||||
- **Storage quota** display
|
|
||||||
- **Shared folders** — multi-account access
|
|
||||||
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
|
||||||
|
|
||||||
### Internationalization
|
|
||||||
|
|
||||||
8 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português
|
|
||||||
|
|
||||||
Automatic browser detection with persistent preference.
|
|
||||||
|
|
||||||
### Identity Management
|
|
||||||
|
|
||||||
- **Multiple sender identities** with per-identity signatures
|
|
||||||
- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions
|
|
||||||
- **Identity badges** in viewer and email list
|
|
||||||
|
|
||||||
### Operations
|
|
||||||
|
|
||||||
- **Automatic update check** — server logs when a newer release is available
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Docker (recommended)
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 3000:3000 \
|
docker run -d -p 3000:3000 \
|
||||||
@@ -206,7 +84,7 @@ Or with Docker Compose:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local — set JMAP_SERVER_URL
|
# Edit .env.local – set JMAP_SERVER_URL
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -217,21 +95,21 @@ git clone https://github.com/bulwarkmail/webmail.git
|
|||||||
cd webmail
|
cd webmail
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local — set JMAP_SERVER_URL
|
# Edit .env.local – set JMAP_SERVER_URL
|
||||||
npm run build && npm start
|
npm run build && npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Start dev server (mock JMAP server included)
|
npm run dev # Dev server with a mock JMAP server
|
||||||
npm run typecheck # Type checking
|
npm run typecheck
|
||||||
npm run lint # Linting
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Edit `.env.local`:
|
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Required
|
# Required
|
||||||
@@ -241,16 +119,25 @@ JMAP_SERVER_URL=https://mail.example.com
|
|||||||
APP_NAME=My Webmail
|
APP_NAME=My Webmail
|
||||||
```
|
```
|
||||||
|
|
||||||
All variables are **runtime** — Docker deployments can be configured without rebuilding.
|
<details>
|
||||||
|
<summary>Server listen address</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
|
||||||
|
PORT=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>OAuth2/OIDC (SSO)</summary>
|
<summary>OAuth2 / OIDC</summary>
|
||||||
|
|
||||||
```env
|
```env
|
||||||
OAUTH_ENABLED=true
|
OAUTH_ENABLED=true
|
||||||
OAUTH_CLIENT_ID=webmail
|
OAUTH_CLIENT_ID=webmail
|
||||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||||
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`.
|
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
|
||||||
@@ -258,13 +145,77 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Remember Me</summary>
|
<summary>Session & settings sync</summary>
|
||||||
|
|
||||||
```env
|
```env
|
||||||
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
SESSION_SECRET= # openssl rand -base64 32
|
||||||
|
SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
|
||||||
|
|
||||||
|
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).
|
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>
|
||||||
|
|
||||||
|
```env
|
||||||
|
ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Branding & PWA</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_NAME=My Webmail
|
||||||
|
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
|
||||||
|
PWA_BACKGROUND_COLOR=#ffffff
|
||||||
|
|
||||||
|
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
|
||||||
|
APP_LOGO_DARK_URL=/branding/logo-dark.svg
|
||||||
|
LOGIN_LOGO_LIGHT_URL=/branding/login-light.svg
|
||||||
|
LOGIN_LOGO_DARK_URL=/branding/login-dark.svg
|
||||||
|
|
||||||
|
LOGIN_COMPANY_NAME=My Company
|
||||||
|
LOGIN_WEBSITE_URL=https://example.com
|
||||||
|
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
|
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Extension directory</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Enables the admin marketplace for browsing and installing plugins and themes.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Stalwart integration & logging</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
STALWART_FEATURES=true # password change, Sieve filters, etc.
|
||||||
|
|
||||||
|
LOG_FORMAT=text # "text" or "json"
|
||||||
|
LOG_LEVEL=info # error | warn | info | debug
|
||||||
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -298,23 +249,16 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
|
|||||||
|
|
||||||
## Why Stalwart?
|
## 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
|
## Contributing
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
See [ROADMAP.md](ROADMAP.md) for planned features and current status.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[GNU AGPL v3](LICENSE)
|
[GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
|
||||||
|
|
||||||
This repository also preserves the original MIT attribution notice for the
|
|
||||||
fork lineage in [NOTICE](NOTICE).
|
|
||||||
|
|
||||||
## Acknowledgments
|
## 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.
|
||||||
|
|||||||
-301
@@ -1,301 +0,0 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" />
|
|
||||||
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" />
|
|
||||||
</picture>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
# Bulwark Webmail - Roadmap
|
|
||||||
|
|
||||||
This document tracks the development status and planned features for Bulwark Webmail.
|
|
||||||
|
|
||||||
## Completed Features
|
|
||||||
|
|
||||||
### Core Infrastructure
|
|
||||||
|
|
||||||
- [x] Next.js 16 with TypeScript and App Router
|
|
||||||
- [x] Tailwind CSS v4 with Oxide engine
|
|
||||||
- [x] Zustand state management
|
|
||||||
- [x] Custom JMAP client implementation (RFC 8620)
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
- [x] Login with JMAP server authentication
|
|
||||||
- [x] Session management (no password storage for security)
|
|
||||||
- [x] Username autocomplete with history
|
|
||||||
- [x] Logout functionality
|
|
||||||
- [x] Authentication error handling
|
|
||||||
- [x] JMAP identities for sender address
|
|
||||||
- [x] TOTP two-factor authentication (Stalwart-compatible)
|
|
||||||
- [x] OAuth2/OIDC with PKCE (opt-in SSO, session persistence, RP-initiated logout)
|
|
||||||
- [x] External IdP support via explicit issuer URL (Keycloak, Authentik, etc.)
|
|
||||||
- [x] "Remember me" session persistence for Basic Auth (AES-256-GCM encrypted httpOnly cookie)
|
|
||||||
|
|
||||||
### JMAP Server Connection
|
|
||||||
|
|
||||||
- [x] Session establishment and keep-alive
|
|
||||||
- [x] Connection error handling and retries
|
|
||||||
- [x] CORS error detection with actionable user guidance
|
|
||||||
- [x] Session URL origin rewriting (fixes Docker/reverse proxy deployments where server returns internal hostname)
|
|
||||||
- [x] Storage quota display
|
|
||||||
- [x] Server capability detection
|
|
||||||
- [x] Shared folders support (multi-account access)
|
|
||||||
|
|
||||||
### Email Operations
|
|
||||||
|
|
||||||
- [x] Email fetching and display
|
|
||||||
- [x] Full HTML email rendering
|
|
||||||
- [x] Compose, reply, reply-all, forward
|
|
||||||
- [x] Draft auto-save with discard confirmation
|
|
||||||
- [x] Mark as read/unread
|
|
||||||
- [x] Star/unstar emails
|
|
||||||
- [x] Delete and archive
|
|
||||||
- [x] Color tags/labels
|
|
||||||
- [x] Full-text search
|
|
||||||
- [x] Advanced search with JMAP filter panel, search chips, and cross-mailbox queries
|
|
||||||
- [x] Attachment upload and download
|
|
||||||
- [x] Batch operations (multi-select)
|
|
||||||
- [x] Quick reply form
|
|
||||||
- [x] Email threading (Gmail-style inline expansion)
|
|
||||||
|
|
||||||
### Real-time Updates
|
|
||||||
|
|
||||||
- [x] EventSource for JMAP push notifications
|
|
||||||
- [x] State synchronization
|
|
||||||
- [x] Email arrival notifications
|
|
||||||
- [x] Real-time unread counts
|
|
||||||
- [x] Mailbox change handling
|
|
||||||
|
|
||||||
### User Interface
|
|
||||||
|
|
||||||
- [x] Three-pane layout (sidebar, list, viewer)
|
|
||||||
- [x] Minimalist design system
|
|
||||||
- [x] Dark and light theme support
|
|
||||||
- [x] Custom scrollbars
|
|
||||||
- [x] Mobile responsive design
|
|
||||||
- [x] Keyboard shortcuts
|
|
||||||
- [x] Drag-and-drop email organization
|
|
||||||
- [x] Right-click context menus
|
|
||||||
- [x] Hierarchical mailbox display
|
|
||||||
- [x] Email list with avatars and visual hierarchy
|
|
||||||
- [x] Expandable email headers
|
|
||||||
- [x] External content warning banner
|
|
||||||
- [x] SPF/DKIM/DMARC status indicators
|
|
||||||
- [x] Loading states and skeletons
|
|
||||||
- [x] Smooth transitions and animations
|
|
||||||
- [x] Infinite scroll pagination
|
|
||||||
- [x] Virtual scrolling for large email lists
|
|
||||||
- [x] Error boundaries
|
|
||||||
- [x] Settings page with preferences
|
|
||||||
- [x] Navigation rail (desktop vertical icon sidebar + mobile bottom tab bar)
|
|
||||||
- [x] Welcome banner for first-time users (one-time display, localStorage persistence)
|
|
||||||
- [x] Confirmation dialog component with promise-based useConfirmDialog hook
|
|
||||||
- [x] Toast notifications with undo action support and typed durations
|
|
||||||
- [x] Inline form validation with shake animation (email composer, contact form)
|
|
||||||
- [x] Login UX polish (error shake, discreet 2FA toggle, password visibility toggle, session expired banner)
|
|
||||||
- [x] Empty state patterns for contacts (distinct "no data" vs "no search results" with contextual actions)
|
|
||||||
- [x] WCAG AA reduced-motion media query (global animation/transition reset)
|
|
||||||
- [x] Safe area inset utilities for notched devices
|
|
||||||
- [x] Screen reader live region announcements (sr-only)
|
|
||||||
|
|
||||||
### Internationalization
|
|
||||||
|
|
||||||
- [x] English language support
|
|
||||||
- [x] French language support
|
|
||||||
- [x] Japanese language support
|
|
||||||
- [x] Spanish language support
|
|
||||||
- [x] Italian language support
|
|
||||||
- [x] German language support
|
|
||||||
- [x] Dutch language support
|
|
||||||
- [x] Portuguese language support
|
|
||||||
- [x] Automatic browser language detection
|
|
||||||
- [x] Language preference persistence
|
|
||||||
|
|
||||||
### Security & Accessibility
|
|
||||||
|
|
||||||
- [x] External content blocked by default
|
|
||||||
- [x] HTML sanitization with DOMPurify
|
|
||||||
- [x] User control for loading external content
|
|
||||||
- [x] Trusted senders list for automatic image loading
|
|
||||||
- [x] Dark mode email readability (intelligent color transformation)
|
|
||||||
- [x] WCAG 2.0 Level AA color contrast compliance
|
|
||||||
- [x] Newsletter unsubscribe support (RFC 2369)
|
|
||||||
- [x] XSS attack prevention with comprehensive validation
|
|
||||||
- [x] CSP Report-Only headers with per-request nonce
|
|
||||||
- [x] Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy)
|
|
||||||
- [x] Reusable focus trap hook (Tab cycling, Escape handling, focus restore)
|
|
||||||
- [x] WCAG AA prefers-reduced-motion support (global animation/transition reset)
|
|
||||||
- [x] Safe area insets for notched mobile devices
|
|
||||||
- [x] Screen reader sr-only live region for dynamic announcements
|
|
||||||
|
|
||||||
### Identity Management
|
|
||||||
|
|
||||||
- [x] Multiple sender identities (name, email, signature)
|
|
||||||
- [x] Sub-addressing support (user+tag@domain.com)
|
|
||||||
- [x] Per-identity signatures
|
|
||||||
- [x] Identity badges in email viewer and list
|
|
||||||
- [x] Tag suggestions based on context
|
|
||||||
- [x] Display name included in From header (recipients see name, not just email)
|
|
||||||
- [x] Primary identity (matching login) selected by default in composer
|
|
||||||
|
|
||||||
### Address Book & Contacts
|
|
||||||
|
|
||||||
- [x] Contact store with JMAP sync and local fallback
|
|
||||||
- [x] Contact CRUD operations (create, read, update, delete)
|
|
||||||
- [x] Contacts list view with search/filter
|
|
||||||
- [x] Contact details view/edit form
|
|
||||||
- [x] JMAP contacts sync (RFC 9553/9610 ContactCard/AddressBook)
|
|
||||||
- [x] Email autocomplete from contacts
|
|
||||||
- [x] Contacts integration in email composer (To/Cc/Bcc)
|
|
||||||
- [x] Contact groups/lists management with JMAP members map
|
|
||||||
- [x] vCard import/export (RFC 6350 parser/generator, duplicate detection)
|
|
||||||
- [x] Bulk contact operations (multi-select, delete, group add, export)
|
|
||||||
- [x] i18n support for contacts (all 8 languages)
|
|
||||||
|
|
||||||
### Vacation Responder
|
|
||||||
|
|
||||||
- [x] JMAP VacationResponse singleton management
|
|
||||||
- [x] Settings tab with date range and message configuration
|
|
||||||
- [x] Sidebar indicator when vacation auto-reply is active
|
|
||||||
- [x] i18n support (all 8 languages)
|
|
||||||
|
|
||||||
### Calendar Integration
|
|
||||||
|
|
||||||
- [x] JMAP Calendar types (RFC 8984) and client methods
|
|
||||||
- [x] Calendar capability detection (urn:ietf:params:jmap:calendars)
|
|
||||||
- [x] Calendar store with Zustand (persist middleware)
|
|
||||||
- [x] Month, week, day, and agenda views
|
|
||||||
- [x] Event modal (create/edit/delete with recurrence, reminders)
|
|
||||||
- [x] Mini-calendar sidebar with calendar visibility toggles
|
|
||||||
- [x] Calendar settings (default view, week start, time format)
|
|
||||||
- [x] Multi-day event spanning across all covered days
|
|
||||||
- [x] Column-based overlap layout for concurrent events
|
|
||||||
- [x] Locale-aware date formatting via next-intl
|
|
||||||
- [x] First day of week and time format settings wired to views
|
|
||||||
- [x] Push notification handling for calendar state changes
|
|
||||||
- [x] Calendar page capability check (redirect if unsupported)
|
|
||||||
- [x] Error handling with toast feedback on event CRUD
|
|
||||||
- [x] Timezone auto-detection on event creation
|
|
||||||
- [x] Input validation, color sanitization, focus trap
|
|
||||||
- [x] ARIA grid roles and event card accessible labels
|
|
||||||
- [x] Mobile touch targets (44px minimum)
|
|
||||||
- [x] Calendar keyboard shortcuts (m/w/d/a views, t today, n new event)
|
|
||||||
- [x] i18n support with ICU pluralization (all 8 languages)
|
|
||||||
- [x] Drag-and-drop event rescheduling (week/day time snap, month date move)
|
|
||||||
- [x] iCalendar (.ics) file import via CalendarEvent/parse with preview and bulk create
|
|
||||||
- [x] Event notifications with client-side alert evaluation and toast display
|
|
||||||
- [x] Notification sound, acknowledged alert persistence (localStorage), proactive 24h event fetch
|
|
||||||
- [x] Configurable notification settings (enable/disable, sound toggle)
|
|
||||||
- [x] Participant scheduling with iTIP invitations (organizer/attendee UI, RSVP buttons, contact autocomplete)
|
|
||||||
- [x] Inline calendar invitation banner in email viewer (auto-detect .ics attachments, RSVP, import to calendar, cancellation display)
|
|
||||||
- [x] Scheduling message support (sendSchedulingMessages flag for create/update/delete)
|
|
||||||
- [x] Click-drag to create events (pointer-based time range selection, 15-min snap, visual overlay)
|
|
||||||
- [x] Event resize by dragging bottom edge handle (15-min snap, optimistic JMAP update)
|
|
||||||
- [x] Recurring event edit/delete scope dialog (this event / this and following / all events)
|
|
||||||
- [x] Double-click quick event creation (inline title input, PT1H default)
|
|
||||||
- [x] Event duplication button in modal (clones event +1 day, opens for editing)
|
|
||||||
|
|
||||||
### Email Filters
|
|
||||||
|
|
||||||
- [x] JMAP Sieve Scripts (RFC 9661) with capability detection
|
|
||||||
- [x] Visual rule builder (conditions: From/To/Cc/Subject/Header/Size/Body, actions: Move/Copy/Forward/Mark read/Star/Label/Discard/Reject/Keep/Stop)
|
|
||||||
- [x] Raw Sieve script editor with syntax validation
|
|
||||||
- [x] Sieve generator and parser with JSON metadata round-trip
|
|
||||||
- [x] Filter store with CRUD, reorder, toggle, auto-save with rollback
|
|
||||||
- [x] Opaque script detection with reset to visual builder option
|
|
||||||
- [x] Focus trap accessibility in modals
|
|
||||||
- [x] Toast validation feedback for empty rules
|
|
||||||
- [x] Push notification handling for SieveScript state changes
|
|
||||||
- [x] i18n support (all 8 languages)
|
|
||||||
|
|
||||||
### Email Templates
|
|
||||||
|
|
||||||
- [x] Reusable email templates with local storage persistence
|
|
||||||
- [x] Category organization (General, Business, Personal, Support, Follow-up, custom)
|
|
||||||
- [x] Dynamic placeholder variables with auto-fill from composer context
|
|
||||||
- [x] Template manager modal (create, edit, duplicate, delete)
|
|
||||||
- [x] Template picker in composer toolbar with search and category filter
|
|
||||||
- [x] Custom placeholder prompt on template insertion
|
|
||||||
- [x] Settings tab for template management
|
|
||||||
- [x] Keyboard shortcut (Ctrl+Shift+T to insert template)
|
|
||||||
- [x] i18n support (all 8 languages)
|
|
||||||
|
|
||||||
### Email Display
|
|
||||||
|
|
||||||
- [x] Proper email layout without horizontal scroll or clipping
|
|
||||||
- [x] Blocked image container collapsing (no empty spaces in newsletters)
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
- [x] Unit tests for validation utilities (57 tests)
|
|
||||||
- [x] Unit tests for email sanitization (27 tests)
|
|
||||||
- [x] Unit tests for color transformation (40 tests)
|
|
||||||
- [x] Unit tests for contact store (56 tests)
|
|
||||||
- [x] Unit tests for JMAP contact client (41 tests)
|
|
||||||
- [x] Unit tests for vCard parser (18 tests)
|
|
||||||
- [x] Unit tests for thread utilities (20 tests)
|
|
||||||
- [x] Unit tests for email headers (39 tests)
|
|
||||||
- [x] Component tests (contacts, UI components — 41 tests)
|
|
||||||
- [x] JMAP client method tests (identity: 20, contacts: 41)
|
|
||||||
- [x] Unit tests for Sieve generator (50 tests)
|
|
||||||
- [x] Unit tests for Sieve parser (14 tests)
|
|
||||||
- [x] Unit tests for calendar alerts (36 tests)
|
|
||||||
- [x] Unit tests for calendar notification store (8 tests)
|
|
||||||
- [x] Unit tests for calendar invitation parsing (25 tests)
|
|
||||||
- [x] Unit tests for calendar participants (26 tests)
|
|
||||||
- [x] Unit tests for template utilities (48 tests)
|
|
||||||
- [x] Unit tests for OAuth PKCE and discovery (14 tests)
|
|
||||||
- [x] XSS attack vector testing
|
|
||||||
- [x] Playwright E2E framework setup
|
|
||||||
|
|
||||||
### Deployment
|
|
||||||
|
|
||||||
- [x] Runtime environment variables (Docker-friendly configuration)
|
|
||||||
- [x] Health check endpoint
|
|
||||||
- [x] Docker support (multi-stage build, docker-compose, standalone output)
|
|
||||||
- [x] Structured server-side logger (text/JSON format, configurable level)
|
|
||||||
- [x] Pre-built Docker image on [Docker Hub](https://hub.docker.com/r/bulwarkmail/webmail) and [GHCR](https://ghcr.io/bulwarkmail/webmail) with multi-arch support (amd64/arm64)
|
|
||||||
- [x] GitHub Actions CI/CD for automated image publishing on releases
|
|
||||||
- [x] CVE remediation: remove npm from production image, upgrade Alpine packages
|
|
||||||
- [x] Server-side update check (logs newer version availability on startup)
|
|
||||||
|
|
||||||
## Planned Features
|
|
||||||
|
|
||||||
### Advanced Features
|
|
||||||
|
|
||||||
- [ ] Free/busy queries (Principal/getAvailability)
|
|
||||||
- [ ] Calendar sharing UI (JMAP Sharing RFC 9670)
|
|
||||||
- [ ] Email encryption (PGP/GPG)
|
|
||||||
|
|
||||||
### Performance Optimizations
|
|
||||||
|
|
||||||
- [ ] Email content caching
|
|
||||||
- [ ] Bundle size optimization
|
|
||||||
- [ ] Service worker for offline support
|
|
||||||
- [ ] Lazy loading for attachments
|
|
||||||
|
|
||||||
### Testing (Remaining)
|
|
||||||
|
|
||||||
- [ ] E2E tests with real JMAP server
|
|
||||||
- [ ] Accessibility testing
|
|
||||||
- [ ] Performance testing
|
|
||||||
|
|
||||||
### Deployment
|
|
||||||
|
|
||||||
- [ ] Production build optimizations
|
|
||||||
- [ ] Monitoring and logging
|
|
||||||
|
|
||||||
### Security Enhancements
|
|
||||||
|
|
||||||
- [ ] Rate limiting
|
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
|
|
||||||
- [ ] Next.js workspace root warning (cosmetic)
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Want to help implement a feature? Check out our [CONTRIBUTING.md](CONTRIBUTING.md) guide!
|
|
||||||
@@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||||
import { Loader2, AlertCircle } from "lucide-react";
|
import { Loader2, AlertCircle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
@@ -13,7 +14,7 @@ function OAuthCallbackInner() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
const { loginWithOAuth } = useAuthStore();
|
const { loginWithOAuth, loginWithServerSso } = useAuthStore();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -32,43 +33,73 @@ function OAuthCallbackInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const savedState = sessionStorage.getItem("oauth_state");
|
const savedState = sessionStorage.getItem("oauth_state");
|
||||||
if (!state || state !== savedState) {
|
|
||||||
setError("invalid_state");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
if (savedState) {
|
||||||
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
// Classic flow - sessionStorage has the PKCE state (same-tab OAuth)
|
||||||
|
if (!state || state !== savedState) {
|
||||||
|
setError("invalid_state");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!codeVerifier || !serverUrl) {
|
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
||||||
setError("missing_params");
|
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
if (!codeVerifier || !serverUrl) {
|
||||||
|
setError("missing_params");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
.then((success) => {
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
if (success) {
|
|
||||||
sessionStorage.removeItem("oauth_state");
|
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
|
||||||
sessionStorage.removeItem("oauth_code_verifier");
|
.then((success) => {
|
||||||
sessionStorage.removeItem("oauth_server_url");
|
if (success) {
|
||||||
let redirectTo = `/${params.locale}`;
|
sessionStorage.removeItem("oauth_state");
|
||||||
try {
|
sessionStorage.removeItem("oauth_code_verifier");
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
sessionStorage.removeItem("oauth_server_url");
|
||||||
if (saved) {
|
sessionStorage.removeItem("oauth_add_account_mode");
|
||||||
sessionStorage.removeItem('redirect_after_login');
|
let redirectTo = `${prefix}/${params.locale}`;
|
||||||
redirectTo = saved;
|
try {
|
||||||
}
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
} catch { /* sessionStorage may be unavailable */ }
|
if (saved) {
|
||||||
router.push(redirectTo);
|
sessionStorage.removeItem('redirect_after_login');
|
||||||
} else {
|
redirectTo = saved;
|
||||||
|
}
|
||||||
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
|
router.push(redirectTo);
|
||||||
|
} else {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
}
|
});
|
||||||
})
|
} else if (state) {
|
||||||
.catch(() => {
|
// Server-side SSO flow - state was stored in encrypted httpOnly cookie
|
||||||
setError("token_exchange_failed");
|
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||||
});
|
loginWithServerSso(code, state)
|
||||||
|
.then((success) => {
|
||||||
|
if (success) {
|
||||||
|
let redirectTo = `${ssoPrefix}/${params.locale}`;
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
|
if (saved) {
|
||||||
|
sessionStorage.removeItem('redirect_after_login');
|
||||||
|
redirectTo = saved;
|
||||||
|
}
|
||||||
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
|
router.push(redirectTo);
|
||||||
|
} else {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError("invalid_state");
|
||||||
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -86,7 +117,7 @@ function OAuthCallbackInner() {
|
|||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.push(`/${params.locale}/login`)}
|
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
|
||||||
>
|
>
|
||||||
{t("oauth_error.back_to_login")}
|
{t("oauth_error.back_to_login")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+637
-67
File diff suppressed because it is too large
Load Diff
+336
-41
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ArrowLeft, Users } from "lucide-react";
|
import { ArrowLeft, Users } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,16 +12,23 @@ import { ContactForm } from "@/components/contacts/contact-form";
|
|||||||
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
||||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
||||||
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
||||||
|
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||||
|
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||||
import { exportContacts } from "@/components/contacts/contact-export";
|
import { exportContacts } from "@/components/contacts/contact-export";
|
||||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, generateUUID } from "@/lib/utils";
|
||||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
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 { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
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 =
|
type View =
|
||||||
| "list"
|
| "list"
|
||||||
@@ -35,13 +41,14 @@ type View =
|
|||||||
| "bulk-add-to-group";
|
| "bulk-add-to-group";
|
||||||
|
|
||||||
export default function ContactsPage() {
|
export default function ContactsPage() {
|
||||||
const router = useRouter();
|
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
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 [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const {
|
const {
|
||||||
contacts,
|
contacts,
|
||||||
|
addressBooks,
|
||||||
selectedContactId,
|
selectedContactId,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
supportsSync,
|
supportsSync,
|
||||||
@@ -67,10 +74,21 @@ export default function ContactsPage() {
|
|||||||
clearSelection,
|
clearSelection,
|
||||||
bulkDeleteContacts,
|
bulkDeleteContacts,
|
||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
|
moveContactToAddressBook,
|
||||||
|
renameAddressBook,
|
||||||
|
removeAddressBook,
|
||||||
|
shareAddressBook,
|
||||||
|
renameKeyword,
|
||||||
|
importContacts,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
|
|
||||||
const [view, setView] = useState<View>("list");
|
const [view, setView] = useState<View>("list");
|
||||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
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 [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
@@ -78,20 +96,27 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
// Panel resize state - sidebar (categories)
|
// Panel resize state - sidebar (categories)
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 180; } catch { return 180; }
|
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; }
|
||||||
});
|
});
|
||||||
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
|
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
|
||||||
const sidebarDragStartWidth = useRef(180);
|
const sidebarDragStartWidth = useRef(256);
|
||||||
|
|
||||||
// Panel resize state - contact list
|
// Panel resize state - contact list
|
||||||
const [listWidth, setListWidth] = useState(() => {
|
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 [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(() => {
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
setInitialCheckDone(true);
|
setInitialCheckDone(true);
|
||||||
});
|
});
|
||||||
@@ -100,9 +125,9 @@ export default function ContactsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
router.push("/login");
|
redirectToLogin();
|
||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client && supportsSync && !hasFetched.current) {
|
if (client && supportsSync && !hasFetched.current) {
|
||||||
@@ -111,36 +136,111 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [client, supportsSync, fetchContacts]);
|
}, [client, supportsSync, fetchContacts]);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
await fetchContacts(client);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
|
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
|
||||||
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
||||||
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
||||||
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
||||||
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
|
const selectedGroupMembers = useMemo(() => selectedGroupId ? getGroupMembers(selectedGroupId) : [], [selectedGroupId, getGroupMembers]);
|
||||||
|
|
||||||
|
// Collect all unique keywords across contacts
|
||||||
|
const allKeywords = useMemo(() => {
|
||||||
|
const kws = new Set<string>();
|
||||||
|
for (const contact of individuals) {
|
||||||
|
if (!contact.keywords) continue;
|
||||||
|
for (const [kw, active] of Object.entries(contact.keywords)) {
|
||||||
|
if (active) kws.add(kw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(kws).sort((a, b) => a.localeCompare(b));
|
||||||
|
}, [individuals]);
|
||||||
|
|
||||||
// Contacts to display based on active category
|
// Contacts to display based on active category
|
||||||
const displayedContacts = useMemo(() => {
|
const displayedContacts = useMemo(() => {
|
||||||
if (activeCategory === "all") return individuals;
|
if (activeCategory === "all") return individuals;
|
||||||
|
if (activeCategory === "uncategorized") {
|
||||||
|
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0);
|
||||||
|
}
|
||||||
|
if ("addressBookId" in activeCategory) {
|
||||||
|
const bookId = activeCategory.addressBookId;
|
||||||
|
return individuals.filter(c => {
|
||||||
|
if (!c.addressBookIds) return false;
|
||||||
|
return c.addressBookIds[bookId] === true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ("keyword" in activeCategory) {
|
||||||
|
return individuals.filter(c => c.keywords?.[activeCategory.keyword]);
|
||||||
|
}
|
||||||
// Show members of the selected group
|
// Show members of the selected group
|
||||||
return getGroupMembers(activeCategory.groupId);
|
return getGroupMembers(activeCategory.groupId);
|
||||||
}, [activeCategory, individuals, getGroupMembers]);
|
}, [activeCategory, individuals, getGroupMembers]);
|
||||||
|
|
||||||
// Label for the current category
|
|
||||||
const categoryLabel = useMemo(() => {
|
|
||||||
if (activeCategory === "all") return t("tabs.all");
|
|
||||||
const group = contacts.find(c => c.id === activeCategory.groupId);
|
|
||||||
return group ? getContactDisplayName(group) : t("tabs.all");
|
|
||||||
}, [activeCategory, contacts, t]);
|
|
||||||
|
|
||||||
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
||||||
setActiveCategory(category);
|
setActiveCategory(category);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
if (typeof category === "object") {
|
if (typeof category === "object" && "groupId" in category) {
|
||||||
setSelectedGroupId(category.groupId);
|
setSelectedGroupId(category.groupId);
|
||||||
|
setView("group-detail");
|
||||||
} else {
|
} else {
|
||||||
setSelectedGroupId(null);
|
setSelectedGroupId(null);
|
||||||
}
|
}
|
||||||
}, [clearSelection]);
|
}, [clearSelection]);
|
||||||
|
|
||||||
|
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await moveContactToAddressBook(client, contactIds, addressBook);
|
||||||
|
const msg = contactIds.length === 1
|
||||||
|
? t("address_books.moved", { name: addressBook.name })
|
||||||
|
: t("address_books.moved_plural", { count: contactIds.length, name: addressBook.name });
|
||||||
|
toast.success(msg);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to move contacts:', error);
|
||||||
|
toast.error(t("address_books.move_failed"));
|
||||||
|
}
|
||||||
|
}, [client, moveContactToAddressBook, t]);
|
||||||
|
|
||||||
|
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
|
||||||
|
if (!client && supportsSync) return;
|
||||||
|
try {
|
||||||
|
for (const contactId of contactIds) {
|
||||||
|
const contact = contacts.find(c => c.id === contactId);
|
||||||
|
if (!contact) continue;
|
||||||
|
const existingKeywords = contact.keywords || {};
|
||||||
|
if (existingKeywords[keyword]) continue; // already has this keyword
|
||||||
|
const updatedKeywords = { ...existingKeywords, [keyword]: true };
|
||||||
|
if (supportsSync && client) {
|
||||||
|
await updateContact(client, contactId, { keywords: updatedKeywords });
|
||||||
|
} else {
|
||||||
|
updateLocalContact(contactId, { keywords: updatedKeywords });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const msg = contactIds.length === 1
|
||||||
|
? t("category_added", { name: keyword })
|
||||||
|
: t("category_added_plural", { count: contactIds.length, name: keyword });
|
||||||
|
toast.success(msg);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add contacts to category:', error);
|
||||||
|
toast.error(t("toast.error_update"));
|
||||||
|
}
|
||||||
|
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
||||||
|
|
||||||
|
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||||
|
return importContacts(
|
||||||
|
supportsSync && client ? client : null,
|
||||||
|
importedContacts
|
||||||
|
);
|
||||||
|
}, [supportsSync, client, importContacts]);
|
||||||
|
|
||||||
const handleSelectContact = (id: string) => {
|
const handleSelectContact = (id: string) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -156,9 +256,7 @@ export default function ContactsPage() {
|
|||||||
setView("edit");
|
setView("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const deleteContactById = useCallback(async (contactId: string) => {
|
||||||
if (!selectedContact) return;
|
|
||||||
|
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: t("delete_confirm_title"),
|
title: t("delete_confirm_title"),
|
||||||
message: t("delete_confirm"),
|
message: t("delete_confirm"),
|
||||||
@@ -169,31 +267,74 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (supportsSync && client) {
|
if (supportsSync && client) {
|
||||||
await deleteContact(client, selectedContact.id);
|
await deleteContact(client, contactId);
|
||||||
} else {
|
} else {
|
||||||
deleteLocalContact(selectedContact.id);
|
deleteLocalContact(contactId);
|
||||||
}
|
}
|
||||||
toast.success(t("toast.deleted"));
|
toast.success(t("toast.deleted"));
|
||||||
setView("list");
|
if (selectedContactId === contactId) setView("list");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete contact:', error);
|
console.error('Failed to delete contact:', error);
|
||||||
toast.error(t("toast.error_delete"));
|
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>) => {
|
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
|
||||||
if (supportsSync && client) {
|
if (supportsSync && client) {
|
||||||
await createContact(client, data);
|
await createContact(client, data);
|
||||||
toast.success(t("toast.created"));
|
toast.success(t("toast.created"));
|
||||||
} else {
|
} else {
|
||||||
const localContact: ContactCard = {
|
const localContact: ContactCard = {
|
||||||
id: `local-${crypto.randomUUID()}`,
|
id: `local-${generateUUID()}`,
|
||||||
addressBookIds: {},
|
addressBookIds: {},
|
||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
addLocalContact(localContact);
|
addLocalContact(localContact);
|
||||||
toast.success(t("toast.created"));
|
toast.success(t("toast.created"));
|
||||||
}
|
}
|
||||||
|
setDefaultBookIdForCreate(undefined);
|
||||||
setView("list");
|
setView("list");
|
||||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||||
|
|
||||||
@@ -211,6 +352,7 @@ export default function ContactsPage() {
|
|||||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
|
setDefaultBookIdForCreate(undefined);
|
||||||
if (view === "group-create" || view === "group-edit") {
|
if (view === "group-create" || view === "group-edit") {
|
||||||
setView(selectedGroup ? "group-detail" : "list");
|
setView(selectedGroup ? "group-detail" : "list");
|
||||||
} else if (view === "bulk-add-to-group") {
|
} else if (view === "bulk-add-to-group") {
|
||||||
@@ -220,7 +362,7 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectGroup = (id: string) => {
|
const _handleSelectGroup = (id: string) => {
|
||||||
setSelectedGroupId(id);
|
setSelectedGroupId(id);
|
||||||
setActiveCategory({ groupId: id });
|
setActiveCategory({ groupId: id });
|
||||||
setView("group-detail");
|
setView("group-detail");
|
||||||
@@ -235,6 +377,35 @@ export default function ContactsPage() {
|
|||||||
setView("group-edit");
|
setView("group-edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditGroupFromSidebar = useCallback((groupId: string) => {
|
||||||
|
setSelectedGroupId(groupId);
|
||||||
|
setActiveCategory({ groupId });
|
||||||
|
setView("group-edit");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: t("groups.delete_confirm_title"),
|
||||||
|
message: t("groups.delete_confirm"),
|
||||||
|
confirmText: t("form.delete"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteGroup(supportsSync && client ? client : null, groupId);
|
||||||
|
toast.success(t("toast.deleted"));
|
||||||
|
if (selectedGroupId === groupId) {
|
||||||
|
setSelectedGroupId(null);
|
||||||
|
setActiveCategory("all");
|
||||||
|
setView("list");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete group:', error);
|
||||||
|
toast.error(t("toast.error_delete"));
|
||||||
|
}
|
||||||
|
}, [confirmDialog, deleteGroup, supportsSync, client, selectedGroupId, t]);
|
||||||
|
|
||||||
const handleDeleteGroup = async () => {
|
const handleDeleteGroup = async () => {
|
||||||
if (!selectedGroup) return;
|
if (!selectedGroup) return;
|
||||||
|
|
||||||
@@ -353,13 +524,15 @@ export default function ContactsPage() {
|
|||||||
const renderRightPanel = () => {
|
const renderRightPanel = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "create":
|
case "create":
|
||||||
return <ContactForm onSave={handleSaveNew} onCancel={handleCancel} />;
|
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||||
|
|
||||||
case "edit":
|
case "edit":
|
||||||
if (!selectedContact) return null;
|
if (!selectedContact) return null;
|
||||||
return (
|
return (
|
||||||
<ContactForm
|
<ContactForm
|
||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
|
addressBooks={addressBooks}
|
||||||
|
allKeywords={allKeywords}
|
||||||
onSave={handleSaveEdit}
|
onSave={handleSaveEdit}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
/>
|
/>
|
||||||
@@ -377,7 +550,6 @@ export default function ContactsPage() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onSelectMember={(id) => {
|
onSelectMember={(id) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
setActiveCategory("all");
|
|
||||||
setView("detail");
|
setView("detail");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -452,6 +624,16 @@ export default function ContactsPage() {
|
|||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onAddToGroup={
|
||||||
|
selectedContact
|
||||||
|
? () => handleAddContactToGroup(selectedContact.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDuplicate={
|
||||||
|
selectedContact
|
||||||
|
? () => void handleDuplicateContact(selectedContact)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -467,7 +649,7 @@ export default function ContactsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background overflow-hidden">
|
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
||||||
{/* Navigation Rail - desktop only */}
|
{/* Navigation Rail - desktop only */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
@@ -475,13 +657,20 @@ export default function ContactsPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={logout}
|
||||||
|
onManageApps={handleManageApps}
|
||||||
|
onInlineApp={handleInlineApp}
|
||||||
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
activeAppId={inlineApp?.id ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||||
<div className="flex flex-1 min-h-0">
|
{inlineApp && (
|
||||||
|
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||||
|
)}
|
||||||
|
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||||
{showListPanel && (
|
{showListPanel && (
|
||||||
<>
|
<>
|
||||||
{/* Panel 1: Categories sidebar */}
|
{/* Panel 1: Categories sidebar */}
|
||||||
@@ -497,26 +686,55 @@ export default function ContactsPage() {
|
|||||||
<ContactsSidebar
|
<ContactsSidebar
|
||||||
groups={groups}
|
groups={groups}
|
||||||
individuals={individuals}
|
individuals={individuals}
|
||||||
|
addressBooks={addressBooks}
|
||||||
activeCategory={activeCategory}
|
activeCategory={activeCategory}
|
||||||
onSelectCategory={handleSelectCategory}
|
onSelectCategory={handleSelectCategory}
|
||||||
onCreateGroup={handleCreateGroup}
|
onCreateGroup={handleCreateGroup}
|
||||||
onCreateContact={handleCreateNew}
|
onCreateContact={handleCreateNew}
|
||||||
|
onImport={() => setShowImportDialog(true)}
|
||||||
|
onEditGroup={handleEditGroupFromSidebar}
|
||||||
|
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||||
|
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)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||||
onResize={(delta) => setSidebarWidth(Math.max(140, Math.min(300, sidebarDragStartWidth.current + delta)))}
|
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||||
onResizeEnd={() => {
|
onResizeEnd={() => {
|
||||||
setIsSidebarResizing(false);
|
setIsSidebarResizing(false);
|
||||||
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => { setSidebarWidth(180); localStorage.setItem("contacts-sidebar-width", "180"); }}
|
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Panel 2: Contact list */}
|
{/* Panel 2: Contact list */}
|
||||||
<div
|
<div
|
||||||
|
data-tour="contacts-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
||||||
isMobile ? "w-full" : "",
|
isMobile ? "w-full" : "",
|
||||||
@@ -531,7 +749,6 @@ export default function ContactsPage() {
|
|||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
onSelectContact={handleSelectContact}
|
onSelectContact={handleSelectContact}
|
||||||
onCreateNew={handleCreateNew}
|
onCreateNew={handleCreateNew}
|
||||||
categoryLabel={categoryLabel}
|
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
selectedContactIds={selectedContactIds}
|
selectedContactIds={selectedContactIds}
|
||||||
onToggleSelection={toggleContactSelection}
|
onToggleSelection={toggleContactSelection}
|
||||||
@@ -541,6 +758,9 @@ export default function ContactsPage() {
|
|||||||
onBulkDelete={handleBulkDelete}
|
onBulkDelete={handleBulkDelete}
|
||||||
onBulkAddToGroup={handleBulkAddToGroup}
|
onBulkAddToGroup={handleBulkAddToGroup}
|
||||||
onBulkExport={handleBulkExport}
|
onBulkExport={handleBulkExport}
|
||||||
|
onEditContact={handleEditContact}
|
||||||
|
onDeleteContact={handleDeleteContact}
|
||||||
|
onAddContactToGroup={handleAddContactToGroup}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -552,7 +772,7 @@ export default function ContactsPage() {
|
|||||||
setIsListResizing(false);
|
setIsListResizing(false);
|
||||||
localStorage.setItem("contacts-list-width", String(listWidth));
|
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"); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -570,7 +790,7 @@ export default function ContactsPage() {
|
|||||||
className="touch-manipulation"
|
className="touch-manipulation"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
{t("back_to_mail")}
|
{t("back_to_contacts")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -582,11 +802,86 @@ export default function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<NavigationRail orientation="horizontal" />
|
<NavigationRail
|
||||||
|
orientation="horizontal"
|
||||||
|
onManageApps={handleManageApps}
|
||||||
|
onInlineApp={handleInlineApp}
|
||||||
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
{renamingKeyword !== null && (
|
||||||
|
<RenameDialog
|
||||||
|
currentName={renamingKeyword}
|
||||||
|
title={t("rename_category")}
|
||||||
|
label={t("category_name_label")}
|
||||||
|
onCancel={() => setRenamingKeyword(null)}
|
||||||
|
onConfirm={async (newName) => {
|
||||||
|
try {
|
||||||
|
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
|
||||||
|
toast.success(t("category_renamed"));
|
||||||
|
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
|
||||||
|
setActiveCategory({ keyword: newName.trim() });
|
||||||
|
}
|
||||||
|
setRenamingKeyword(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename category:", err);
|
||||||
|
toast.error(t("category_rename_failed"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{renamingAddressBook && (
|
||||||
|
<RenameDialog
|
||||||
|
currentName={renamingAddressBook.name}
|
||||||
|
title={t("address_books.rename")}
|
||||||
|
label={t("address_books.name_label")}
|
||||||
|
onCancel={() => setRenamingAddressBook(null)}
|
||||||
|
onConfirm={async (newName) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await renameAddressBook(client, renamingAddressBook, newName);
|
||||||
|
toast.success(t("address_books.renamed"));
|
||||||
|
setRenamingAddressBook(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename address book:", err);
|
||||||
|
toast.error(t("address_books.rename_failed"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showImportDialog && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||||
|
<ContactImportDialog
|
||||||
|
existingContacts={contacts}
|
||||||
|
onImport={handleImportContacts}
|
||||||
|
onClose={() => setShowImportDialog(false)}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-28
@@ -7,23 +7,31 @@ import { ArrowLeft } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
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 { useIsMobile } from "@/hooks/use-media-query";
|
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 { FileBrowser } from "@/components/files/file-browser";
|
||||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("files");
|
const t = useTranslations("files");
|
||||||
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||||
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const {
|
const {
|
||||||
@@ -41,7 +49,7 @@ export default function FilesPage() {
|
|||||||
navigateByPath,
|
navigateByPath,
|
||||||
refresh,
|
refresh,
|
||||||
createDirectory,
|
createDirectory,
|
||||||
uploadFile,
|
uploadFile: _uploadFile,
|
||||||
uploadFiles,
|
uploadFiles,
|
||||||
uploadFolder,
|
uploadFolder,
|
||||||
deleteResource,
|
deleteResource,
|
||||||
@@ -97,8 +105,15 @@ export default function FilesPage() {
|
|||||||
|
|
||||||
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
|
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(() => {
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
setInitialCheckDone(true);
|
setInitialCheckDone(true);
|
||||||
});
|
});
|
||||||
@@ -108,9 +123,9 @@ export default function FilesPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
router.push("/login");
|
redirectToLogin();
|
||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
// Initialize JMAP files client
|
// Initialize JMAP files client
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -120,6 +135,15 @@ export default function FilesPage() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, client, initClient]);
|
}, [isAuthenticated, client, initClient]);
|
||||||
|
|
||||||
|
// 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
|
// Check support and load root after client is initialized
|
||||||
const storeClient = useFileStore(s => s.client);
|
const storeClient = useFileStore(s => s.client);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -152,39 +176,43 @@ export default function FilesPage() {
|
|||||||
}
|
}
|
||||||
}, [createDirectory, t]);
|
}, [createDirectory, t]);
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
const maxSizeUpload = client?.getMaxSizeUpload() || 0;
|
||||||
|
|
||||||
const handleUploadFiles = useCallback(async (files: File[]) => {
|
const handleUploadFiles = useCallback(async (files: File[]) => {
|
||||||
const oversized = files.filter(f => f.size > MAX_FILE_SIZE);
|
if (maxSizeUpload > 0) {
|
||||||
const valid = files.filter(f => f.size <= MAX_FILE_SIZE);
|
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||||||
if (oversized.length > 0) {
|
files = files.filter(f => f.size <= maxSizeUpload);
|
||||||
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" }));
|
if (oversized.length > 0) {
|
||||||
|
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (valid.length === 0) return;
|
if (files.length === 0) return;
|
||||||
try {
|
try {
|
||||||
await uploadFiles(valid);
|
await uploadFiles(files);
|
||||||
toast.success(t("upload_success", { count: valid.length }));
|
toast.success(t("upload_success", { count: files.length }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to upload files:", err);
|
console.error("Failed to upload files:", err);
|
||||||
toast.error(t("upload_error"));
|
toast.error(t("upload_error"));
|
||||||
}
|
}
|
||||||
}, [uploadFiles, t]);
|
}, [uploadFiles, t, maxSizeUpload]);
|
||||||
|
|
||||||
const handleUploadFolder = useCallback(async (files: File[]) => {
|
const handleUploadFolder = useCallback(async (files: File[]) => {
|
||||||
const oversized = files.filter(f => f.size > MAX_FILE_SIZE);
|
if (maxSizeUpload > 0) {
|
||||||
const valid = files.filter(f => f.size <= MAX_FILE_SIZE);
|
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||||||
if (oversized.length > 0) {
|
files = files.filter(f => f.size <= maxSizeUpload);
|
||||||
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" }));
|
if (oversized.length > 0) {
|
||||||
|
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (valid.length === 0) return;
|
if (files.length === 0) return;
|
||||||
try {
|
try {
|
||||||
await uploadFolder(valid);
|
await uploadFolder(files);
|
||||||
toast.success(t("upload_success", { count: valid.length }));
|
toast.success(t("upload_success", { count: files.length }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to upload folder:", err);
|
console.error("Failed to upload folder:", err);
|
||||||
toast.error(t("upload_error"));
|
toast.error(t("upload_error"));
|
||||||
}
|
}
|
||||||
}, [uploadFolder, t]);
|
}, [uploadFolder, t, maxSizeUpload]);
|
||||||
|
|
||||||
const handleDelete = useCallback(async (name: string) => {
|
const handleDelete = useCallback(async (name: string) => {
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
@@ -353,13 +381,20 @@ export default function FilesPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={logout}
|
||||||
|
onManageApps={handleManageApps}
|
||||||
|
onInlineApp={handleInlineApp}
|
||||||
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
activeAppId={inlineApp?.id ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<div className="flex flex-1 min-h-0">
|
{inlineApp && (
|
||||||
|
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||||
|
)}
|
||||||
|
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
{folderLayout !== "sidebar" && (
|
{folderLayout !== "sidebar" && (
|
||||||
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
|
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
|
||||||
@@ -377,12 +412,25 @@ export default function FilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
{supportsFiles === false ? (
|
{!filesEnabled ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="max-w-lg text-center space-y-3 px-4">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
|
||||||
|
<p className="text-sm font-medium">{t("disabled_title")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : supportsFiles === false ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-3 py-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-yellow-700 dark:text-yellow-400">{t("stability_warning")}</p>
|
||||||
|
</div>
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
resources={resources}
|
resources={resources}
|
||||||
@@ -427,13 +475,20 @@ export default function FilesPage() {
|
|||||||
onToggleDetails={handleToggleDetails}
|
onToggleDetails={handleToggleDetails}
|
||||||
detailResource={detailResource}
|
detailResource={detailResource}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<NavigationRail orientation="horizontal" />
|
<NavigationRail
|
||||||
|
orientation="horizontal"
|
||||||
|
onManageApps={handleManageApps}
|
||||||
|
onInlineApp={handleInlineApp}
|
||||||
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -457,6 +512,7 @@ export default function FilesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,9 @@ import { notFound } from "next/navigation";
|
|||||||
import { IntlProvider } from "@/components/providers/intl-provider";
|
import { IntlProvider } from "@/components/providers/intl-provider";
|
||||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||||
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
||||||
|
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 { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
@@ -26,7 +29,13 @@ export default async function LocaleLayout({
|
|||||||
<IntlProvider locale={locale} messages={messages}>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<CalendarAlertProvider>
|
<CalendarAlertProvider>
|
||||||
{children}
|
<RateLimitToastProvider>
|
||||||
|
<EmbeddedBridgeProvider>
|
||||||
|
<TourProvider>
|
||||||
|
{children}
|
||||||
|
</TourProvider>
|
||||||
|
</EmbeddedBridgeProvider>
|
||||||
|
</RateLimitToastProvider>
|
||||||
</CalendarAlertProvider>
|
</CalendarAlertProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
|
|||||||
+360
-32
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -10,13 +10,15 @@ import { useAuthStore } from "@/stores/auth-store";
|
|||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
|
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } from "lucide-react";
|
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 { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||||
|
|
||||||
const APP_VERSION = "1.2.4";
|
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||||
|
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||||
@@ -24,19 +26,57 @@ const THEME_OPTIONS = [
|
|||||||
{ value: "system" as const, icon: Monitor, label: "System" },
|
{ value: "system" as const, icon: Monitor, label: "System" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function VersionBadge() {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(versionInfo).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
aria-label="Copy version info"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const searchParams = useSearchParams();
|
||||||
|
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||||
|
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 { 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, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
|
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 resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
|
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
||||||
const [totpCode, setTotpCode] = useState("");
|
const [totpCode, setTotpCode] = useState("");
|
||||||
const [showTotpField, setShowTotpField] = useState(false);
|
const [showTotpField, setShowTotpField] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
@@ -52,6 +92,7 @@ export default function LoginPage() {
|
|||||||
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
|
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
|
||||||
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
|
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
|
||||||
const [oauthLoading, setOauthLoading] = useState(false);
|
const [oauthLoading, setOauthLoading] = useState(false);
|
||||||
|
const [demoLoading, setDemoLoading] = useState(false);
|
||||||
|
|
||||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -70,6 +111,12 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [appName, serverUrl]);
|
}, [appName, serverUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverUrl && !jmapEndpoint) {
|
||||||
|
setJmapEndpoint(serverUrl);
|
||||||
|
}
|
||||||
|
}, [serverUrl, jmapEndpoint]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
if (sessionStorage.getItem('session_expired') === 'true') {
|
if (sessionStorage.getItem('session_expired') === 'true') {
|
||||||
@@ -88,6 +135,14 @@ export default function LoginPage() {
|
|||||||
prevError.current = error;
|
prevError.current = error;
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
|
// Auto-show and focus TOTP field when server requires it
|
||||||
|
useEffect(() => {
|
||||||
|
if (error === 'totp_required') {
|
||||||
|
setShowTotpField(true);
|
||||||
|
setTimeout(() => totpInputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverUrl) return;
|
if (!serverUrl) return;
|
||||||
const saved = localStorage.getItem("webmail_usernames");
|
const saved = localStorage.getItem("webmail_usernames");
|
||||||
@@ -102,7 +157,7 @@ export default function LoginPage() {
|
|||||||
}, [serverUrl]);
|
}, [serverUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated && !isAddAccountMode) {
|
||||||
let redirectTo = '/';
|
let redirectTo = '/';
|
||||||
try {
|
try {
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
@@ -113,7 +168,7 @@ export default function LoginPage() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(redirectTo);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router]);
|
}, [isAuthenticated, router, isAddAccountMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearError();
|
clearError();
|
||||||
@@ -170,6 +225,64 @@ export default function LoginPage() {
|
|||||||
});
|
});
|
||||||
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
|
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
|
||||||
|
|
||||||
|
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||||
|
const ssoError = searchParams.get("sso_error");
|
||||||
|
const autoSsoTriggered = useRef(false);
|
||||||
|
|
||||||
|
const startServerSideSso = useCallback(async () => {
|
||||||
|
setOauthLoading(true);
|
||||||
|
try {
|
||||||
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
|
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 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setOauthLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { authorize_url } = await res.json();
|
||||||
|
|
||||||
|
// Navigate to the authorize URL
|
||||||
|
const isIframe = (() => { try { return window.self !== window.top; } catch { return true; } })();
|
||||||
|
if (isIframe) {
|
||||||
|
// In an iframe, try top-level navigation
|
||||||
|
try {
|
||||||
|
window.top!.location.href = authorize_url;
|
||||||
|
} catch {
|
||||||
|
// Cross-origin restriction - fall back to current frame
|
||||||
|
window.location.href = authorize_url;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.location.href = authorize_url;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setOauthLoading(false);
|
||||||
|
}
|
||||||
|
}, [params.locale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
||||||
|
if (ssoError || isAddAccountMode || isAuthenticated) return;
|
||||||
|
if (autoSsoTriggered.current) return;
|
||||||
|
|
||||||
|
// Guard against redirect loops
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem("sso_attempted")) return;
|
||||||
|
sessionStorage.setItem("sso_attempted", "1");
|
||||||
|
// Clear the flag after 30 seconds so retries are possible
|
||||||
|
setTimeout(() => { try { sessionStorage.removeItem("sso_attempted"); } catch { /* ignore */ } }, 30000);
|
||||||
|
} catch { /* sessionStorage unavailable */ }
|
||||||
|
|
||||||
|
autoSsoTriggered.current = true;
|
||||||
|
startServerSideSso();
|
||||||
|
}, [autoSsoEnabled, oauthOnly, oauthDiscoveryDone, oauthMetadata, ssoError, isAddAccountMode, isAuthenticated, startServerSideSso]);
|
||||||
|
|
||||||
const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => {
|
const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => {
|
||||||
setTheme(newTheme);
|
setTheme(newTheme);
|
||||||
setShowThemeMenu(false);
|
setShowThemeMenu(false);
|
||||||
@@ -204,7 +317,7 @@ export default function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serverUrl) {
|
if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||||
<div className="w-full max-w-md mx-auto px-4 text-center">
|
<div className="w-full max-w-md mx-auto px-4 text-center">
|
||||||
@@ -302,7 +415,10 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||||
sessionStorage.setItem("oauth_state", state);
|
sessionStorage.setItem("oauth_state", state);
|
||||||
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
|
||||||
|
if (isAddAccountMode) {
|
||||||
|
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||||
|
}
|
||||||
|
|
||||||
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
||||||
authUrl.searchParams.set("response_type", "code");
|
authUrl.searchParams.set("response_type", "code");
|
||||||
@@ -319,8 +435,9 @@ export default function LoginPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
|
||||||
const success = await login(
|
const success = await login(
|
||||||
serverUrl,
|
effectiveServerUrl,
|
||||||
formData.username,
|
formData.username,
|
||||||
formData.password,
|
formData.password,
|
||||||
totpCode || undefined,
|
totpCode || undefined,
|
||||||
@@ -329,15 +446,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
saveUsername(formData.username);
|
saveUsername(formData.username);
|
||||||
let redirectTo = '/';
|
router.push('/');
|
||||||
try {
|
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
|
||||||
if (saved) {
|
|
||||||
sessionStorage.removeItem('redirect_after_login');
|
|
||||||
redirectTo = saved;
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
router.push(redirectTo);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -356,9 +465,165 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDemoLogin = async () => {
|
||||||
|
setDemoLoading(true);
|
||||||
|
const success = await loginDemo();
|
||||||
|
if (success) {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
setDemoLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
|
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
|
||||||
const CurrentThemeIcon = currentThemeOption.icon;
|
const CurrentThemeIcon = currentThemeOption.icon;
|
||||||
|
|
||||||
|
// Demo-only mode: show only a large demo login button
|
||||||
|
if (demoMode && !isAddAccountMode) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowThemeMenu(!showThemeMenu)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200",
|
||||||
|
showThemeMenu
|
||||||
|
? "bg-secondary border-border text-foreground shadow-md"
|
||||||
|
: "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border"
|
||||||
|
)}
|
||||||
|
aria-label={`Theme: ${currentThemeOption.label}`}
|
||||||
|
aria-expanded={showThemeMenu}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
>
|
||||||
|
<CurrentThemeIcon className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showThemeMenu && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Theme selection"
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
const isActive = theme === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => handleThemeSelect(option.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-foreground font-medium"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span className="flex-1 text-left">{option.label}</span>
|
||||||
|
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-[440px] mx-auto">
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
||||||
|
{/* Header with logo */}
|
||||||
|
<div className="px-8 pt-12 pb-4 text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||||
|
<img
|
||||||
|
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||||
|
alt={appName}
|
||||||
|
className="max-w-20 max-h-20 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground tracking-tight">
|
||||||
|
{appName}
|
||||||
|
</h1>
|
||||||
|
<p className="text-base text-muted-foreground mt-2 max-w-xs mx-auto leading-relaxed">
|
||||||
|
{t("demo_tagline")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Large demo button */}
|
||||||
|
<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",
|
||||||
|
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">
|
||||||
|
{t(`error.${error}`) || t("error.generic")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-14 font-semibold text-lg bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
onClick={handleDemoLogin}
|
||||||
|
disabled={demoLoading || isLoading}
|
||||||
|
>
|
||||||
|
{demoLoading ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
{t("demo_launching")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Play className="w-5 h-5" />
|
||||||
|
{t("demo_login_button")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground mt-4 leading-relaxed">
|
||||||
|
{t("demo_no_signup")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-6 flex flex-col items-center gap-2">
|
||||||
|
{loginCompanyName && (
|
||||||
|
<p className="text-center text-xs text-muted-foreground/60 font-medium">
|
||||||
|
{loginCompanyName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap justify-center">
|
||||||
|
{loginWebsiteUrl && (
|
||||||
|
<a href={loginWebsiteUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||||
|
{t("website")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{loginImprintUrl && (
|
||||||
|
<a href={loginImprintUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||||
|
{t("imprint")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{loginPrivacyPolicyUrl && (
|
||||||
|
<a href={loginPrivacyPolicyUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||||
|
{t("privacy_policy")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<VersionBadge />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||||
{/* Theme toggle - top right, dropdown style */}
|
{/* Theme toggle - top right, dropdown style */}
|
||||||
@@ -426,10 +691,10 @@ export default function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||||
{appName}
|
{isAddAccountMode ? t("add_account_title") : appName}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1.5">
|
<p className="text-sm text-muted-foreground mt-1.5">
|
||||||
{t("title") !== appName ? t("title") : "Sign in to your account"}
|
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -494,7 +759,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
Dev mode — logging in as dev@localhost
|
Dev mode - logging in as dev@localhost
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : oauthOnly ? (
|
) : oauthOnly ? (
|
||||||
@@ -520,9 +785,9 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : oauthDiscoveryDone ? (
|
) : oauthDiscoveryDone ? (
|
||||||
<div className="p-3.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-2">
|
<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-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-400">
|
<p className="text-sm text-warning">
|
||||||
{t("error.oauth_discovery_failed")}
|
{t("error.oauth_discovery_failed")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -536,6 +801,27 @@ export default function LoginPage() {
|
|||||||
/* Login Form */
|
/* Login Form */
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<fieldset disabled={isLoading} className="space-y-4">
|
<fieldset disabled={isLoading} className="space-y-4">
|
||||||
|
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
|
||||||
|
{allowCustomJmapEndpoint && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
|
||||||
|
{t("jmap_endpoint_label")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="jmap-endpoint"
|
||||||
|
type="url"
|
||||||
|
value={jmapEndpoint}
|
||||||
|
onChange={(e) => setJmapEndpoint(e.target.value)}
|
||||||
|
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
||||||
|
placeholder={t("jmap_endpoint_placeholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
{t("jmap_endpoint_cors_hint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Username field */}
|
{/* Username field */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
||||||
@@ -648,7 +934,10 @@ export default function LoginPage() {
|
|||||||
maxLength={6}
|
maxLength={6}
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||||
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest"
|
className={cn(
|
||||||
|
"h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest",
|
||||||
|
error === 'totp_required' && "border-primary ring-2 ring-primary/30"
|
||||||
|
)}
|
||||||
placeholder={t("totp_placeholder")}
|
placeholder={t("totp_placeholder")}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
aria-label={t("totp_label")}
|
aria-label={t("totp_label")}
|
||||||
@@ -728,15 +1017,56 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
|
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
|
||||||
<div className="mt-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-2">
|
<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-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-400">
|
<p className="text-sm text-warning">
|
||||||
{t("error.oauth_discovery_failed")}
|
{t("error.oauth_discovery_failed")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAddAccountMode && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full h-10 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Demo Mode Button */}
|
||||||
|
{demoMode && !isAddAccountMode && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-border/40">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 font-medium text-[15px] rounded-xl border-border/60 hover:bg-muted/50"
|
||||||
|
onClick={handleDemoLogin}
|
||||||
|
disabled={demoLoading || isLoading}
|
||||||
|
>
|
||||||
|
{demoLoading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
{t("demo_launching")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
{t("try_demo")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-xs text-muted-foreground mt-2">
|
||||||
|
{t("demo_description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -781,9 +1111,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-center text-xs text-muted-foreground/40">
|
<VersionBadge />
|
||||||
v{APP_VERSION}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+880
-148
File diff suppressed because it is too large
Load Diff
+225
-74
@@ -9,7 +9,6 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Palette,
|
Palette,
|
||||||
Mail,
|
|
||||||
User,
|
User,
|
||||||
Shield,
|
Shield,
|
||||||
UserPen,
|
UserPen,
|
||||||
@@ -20,78 +19,156 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Tags,
|
Tags,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Wrench,
|
|
||||||
BookUser,
|
BookUser,
|
||||||
|
KeyRound,
|
||||||
|
PanelLeftClose,
|
||||||
|
Bell,
|
||||||
|
Puzzle,
|
||||||
|
LayoutGrid,
|
||||||
|
BookOpen,
|
||||||
|
PenLine,
|
||||||
|
EyeOff,
|
||||||
|
Languages,
|
||||||
|
Info,
|
||||||
|
Bug,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||||
import { EmailSettings } from '@/components/settings/email-settings';
|
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 { AccountSettings } from '@/components/settings/account-settings';
|
||||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||||
import { CalendarSettings } from '@/components/settings/calendar-settings';
|
import { CalendarSettings } from '@/components/settings/calendar-settings';
|
||||||
import { CalendarManagementSettings } from '@/components/settings/calendar-management-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 { FilterSettings } from '@/components/settings/filter-settings';
|
||||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
import { AboutDataSettings } from '@/components/settings/about-data-settings';
|
||||||
|
import { DebugSettings } from '@/components/settings/debug-settings';
|
||||||
import { FolderSettings } from '@/components/settings/folder-settings';
|
import { FolderSettings } from '@/components/settings/folder-settings';
|
||||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
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 { useEmailStore } from '@/stores/email-store';
|
||||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
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 { ResizeHandle } from '@/components/layout/resize-handle';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced';
|
type Tab =
|
||||||
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
| 'account'
|
||||||
|
| 'language'
|
||||||
|
| 'notifications'
|
||||||
|
| 'appearance'
|
||||||
|
| 'layout'
|
||||||
|
| 'reading'
|
||||||
|
| 'composing'
|
||||||
|
| 'identities'
|
||||||
|
| 'vacation'
|
||||||
|
| 'filters'
|
||||||
|
| 'templates'
|
||||||
|
| 'folders'
|
||||||
|
| 'keywords'
|
||||||
|
| 'security'
|
||||||
|
| 'encryption'
|
||||||
|
| 'content_senders'
|
||||||
|
| 'calendar'
|
||||||
|
| 'contacts'
|
||||||
|
| 'files'
|
||||||
|
| 'sidebar_apps'
|
||||||
|
| 'about_data'
|
||||||
|
| 'themes'
|
||||||
|
| 'plugins'
|
||||||
|
| 'debug';
|
||||||
|
|
||||||
|
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
id: Tab;
|
id: Tab;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
group: TabGroup;
|
group: TabGroup;
|
||||||
|
experimental?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabIcons: Record<Tab, LucideIcon> = {
|
const tabIcons: Record<Tab, LucideIcon> = {
|
||||||
appearance: Palette,
|
|
||||||
email: Mail,
|
|
||||||
account: User,
|
account: User,
|
||||||
security: Shield,
|
language: Languages,
|
||||||
|
notifications: Bell,
|
||||||
|
appearance: Palette,
|
||||||
|
layout: LayoutGrid,
|
||||||
|
reading: BookOpen,
|
||||||
|
composing: PenLine,
|
||||||
identities: UserPen,
|
identities: UserPen,
|
||||||
vacation: PalmtreeIcon,
|
vacation: PalmtreeIcon,
|
||||||
calendar: Calendar,
|
|
||||||
contacts: BookUser,
|
|
||||||
filters: Filter,
|
filters: Filter,
|
||||||
templates: FileText,
|
templates: FileText,
|
||||||
folders: FolderOpen,
|
folders: FolderOpen,
|
||||||
keywords: Tags,
|
keywords: Tags,
|
||||||
|
security: Shield,
|
||||||
|
encryption: KeyRound,
|
||||||
|
content_senders: EyeOff,
|
||||||
|
calendar: Calendar,
|
||||||
|
contacts: BookUser,
|
||||||
files: HardDrive,
|
files: HardDrive,
|
||||||
advanced: Wrench,
|
sidebar_apps: PanelLeftClose,
|
||||||
|
about_data: Info,
|
||||||
|
themes: Palette,
|
||||||
|
plugins: Puzzle,
|
||||||
|
debug: Bug,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
|
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
|
||||||
|
|
||||||
|
// 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() {
|
export default function SettingsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations('settings');
|
const t = useTranslations('settings');
|
||||||
const tSidebar = useTranslations('sidebar');
|
const tSidebar = useTranslations('sidebar');
|
||||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
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 [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const { stalwartFeaturesEnabled } = useConfig();
|
const { stalwartFeaturesEnabled } = useConfig();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
try {
|
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
||||||
const saved = localStorage.getItem('settings-active-tab');
|
|
||||||
if (saved) return saved as Tab;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
return 'appearance';
|
|
||||||
});
|
|
||||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
|
|
||||||
@@ -102,19 +179,39 @@ export default function SettingsPage() {
|
|||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const dragStartWidth = useRef(256);
|
const dragStartWidth = useRef(256);
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
setInitialCheckDone(true);
|
setInitialCheckDone(true);
|
||||||
});
|
});
|
||||||
}, [checkAuth]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
router.push('/login');
|
redirectToLogin();
|
||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return null;
|
return null;
|
||||||
@@ -126,20 +223,41 @@ export default function SettingsPage() {
|
|||||||
const supportsFiles = client?.supportsFiles() ?? false;
|
const supportsFiles = client?.supportsFiles() ?? false;
|
||||||
|
|
||||||
const tabs: TabDef[] = [
|
const tabs: TabDef[] = [
|
||||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
|
// General
|
||||||
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
|
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
|
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
||||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
|
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
|
|
||||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
|
// Appearance
|
||||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
|
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||||
{ id: 'templates', label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' },
|
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
|
||||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
|
|
||||||
{ id: 'keywords', label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' },
|
// 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 }] : []),
|
...(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' },
|
{ 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 }] : []),
|
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||||
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
|
...(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, experimental: true }] : []),
|
||||||
|
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup, experimental: true }] : []),
|
||||||
|
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Group tabs by category
|
// Group tabs by category
|
||||||
@@ -151,6 +269,10 @@ export default function SettingsPage() {
|
|||||||
}))
|
}))
|
||||||
.filter((g) => g.items.length > 0);
|
.filter((g) => g.items.length > 0);
|
||||||
|
|
||||||
|
// 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) => {
|
const handleTabSelect = (tabId: Tab) => {
|
||||||
setActiveTab(tabId);
|
setActiveTab(tabId);
|
||||||
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
||||||
@@ -159,34 +281,42 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label ?? '';
|
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||||
|
|
||||||
const renderTabContent = () => (
|
const renderTabContent = () => (
|
||||||
<>
|
<>
|
||||||
{activeTab === 'appearance' && <AppearanceSettings />}
|
{effectiveActiveTab === 'account' && <AccountSettings />}
|
||||||
{activeTab === 'email' && <EmailSettings />}
|
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
||||||
{activeTab === 'account' && <AccountSettings />}
|
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
||||||
{activeTab === 'security' && <AccountSecuritySettings />}
|
{effectiveActiveTab === 'appearance' && <AppearanceSettings />}
|
||||||
{activeTab === 'identities' && <IdentitySettings />}
|
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
||||||
{activeTab === 'vacation' && <VacationSettings />}
|
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||||
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||||
{activeTab === 'contacts' && <ContactsSettings />}
|
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||||
{activeTab === 'filters' && <FilterSettings />}
|
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||||
{activeTab === 'templates' && <TemplateSettings />}
|
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||||
{activeTab === 'folders' && <FolderSettings />}
|
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
||||||
{activeTab === 'keywords' && <KeywordSettings />}
|
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||||
{activeTab === 'files' && <FilesSettingsComponent />}
|
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
{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 === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||||
|
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
||||||
|
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||||
|
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||||
|
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mobile layout
|
// Mobile layout
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
// Mobile: show content view
|
|
||||||
if (mobileShowContent) {
|
if (mobileShowContent) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh bg-background">
|
<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">
|
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -199,23 +329,26 @@ export default function SettingsPage() {
|
|||||||
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
|
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<div className="bg-card border border-border rounded-lg p-4">
|
<div className="bg-card border border-border rounded-lg p-4">
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Navigation */}
|
<NavigationRail
|
||||||
<NavigationRail orientation="horizontal" />
|
orientation="horizontal"
|
||||||
|
onManageApps={handleManageApps}
|
||||||
|
onInlineApp={handleInlineApp}
|
||||||
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
/>
|
||||||
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile: show tab list
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh bg-background">
|
<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">
|
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -231,7 +364,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab list */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
{groupedTabs.map((group, groupIndex) => (
|
{groupedTabs.map((group, groupIndex) => (
|
||||||
@@ -253,6 +385,11 @@ export default function SettingsPage() {
|
|||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||||
{tab.label}
|
{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>
|
</span>
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -262,10 +399,9 @@ export default function SettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logout */}
|
|
||||||
<div className="border-t border-border px-5 py-3">
|
<div className="border-t border-border px-5 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => { logout(); router.push('/login'); }}
|
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"
|
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" />
|
<LogOut className="w-4 h-4" />
|
||||||
@@ -274,8 +410,14 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Navigation */}
|
<NavigationRail
|
||||||
<NavigationRail orientation="horizontal" />
|
orientation="horizontal"
|
||||||
|
onManageApps={handleManageApps}
|
||||||
|
onInlineApp={handleInlineApp}
|
||||||
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
/>
|
||||||
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -283,17 +425,24 @@ export default function SettingsPage() {
|
|||||||
// Desktop layout
|
// Desktop layout
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background">
|
<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)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={logout}
|
||||||
|
onManageApps={handleManageApps}
|
||||||
|
onInlineApp={handleInlineApp}
|
||||||
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
activeAppId={inlineApp?.id ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings Sidebar */}
|
{inlineApp && (
|
||||||
|
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||||
|
)}
|
||||||
|
{!inlineApp && (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-secondary flex flex-col",
|
"border-r border-border bg-secondary flex flex-col",
|
||||||
@@ -301,7 +450,6 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
style={{ width: `${settingsSidebarWidth}px` }}
|
style={{ width: `${settingsSidebarWidth}px` }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div className="p-4 border-b border-border">
|
<div className="p-4 border-b border-border">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -314,8 +462,7 @@ export default function SettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
|
||||||
<div className="px-2 space-y-0.5">
|
<div className="px-2 space-y-0.5">
|
||||||
{groupedTabs.map((group, groupIndex) => (
|
{groupedTabs.map((group, groupIndex) => (
|
||||||
<div key={group.group}>
|
<div key={group.group}>
|
||||||
@@ -333,16 +480,21 @@ export default function SettingsPage() {
|
|||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'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
|
effectiveActiveTab === tab.id
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className={cn(
|
<Icon className={cn(
|
||||||
'w-4 h-4 shrink-0',
|
'w-4 h-4 shrink-0',
|
||||||
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
)} />
|
)} />
|
||||||
{tab.label}
|
{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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -352,7 +504,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar resize handle */}
|
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
|
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
|
||||||
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||||
@@ -363,10 +514,8 @@ export default function SettingsPage() {
|
|||||||
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
|
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Settings Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-3xl mx-auto p-8">
|
<div className="max-w-3xl mx-auto p-8">
|
||||||
{/* Page Header */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-2.5 mb-2">
|
<div className="flex items-center gap-2.5 mb-2">
|
||||||
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
|
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
|
||||||
@@ -374,12 +523,14 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Tab Content */}
|
|
||||||
<div className="bg-card border border-border rounded-lg p-6">
|
<div className="bg-card border border-border rounded-lg p-6">
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, Loader2, RotateCcw, Sparkles } 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 [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 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Auto-setup */}
|
||||||
|
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||||||
|
<div className="flex items-start justify-between 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>
|
||||||
|
|
||||||
|
{/* Auto-setup dialog */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface ConfigEntry {
|
||||||
|
value: unknown;
|
||||||
|
source: 'admin' | 'env' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_FIELDS = [
|
||||||
|
{ key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' },
|
||||||
|
{ key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEXT_FIELDS = [
|
||||||
|
{ key: 'loginCompanyName', label: 'Company Name' },
|
||||||
|
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
||||||
|
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
||||||
|
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminBrandingPage() {
|
||||||
|
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 [uploading, setUploading] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const fileInputRefs = useRef<Record<string, HTMLInputElement | 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: string) {
|
||||||
|
setEdits(prev => ({ ...prev, [key]: value }));
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue(key: string): string {
|
||||||
|
if (key in edits) return edits[key] as string;
|
||||||
|
return (config[key]?.value as string) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'Branding updated. Changes visible on next page load.' });
|
||||||
|
setEdits({});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(slot: string, file: File) {
|
||||||
|
setUploading(slot);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('slot', slot);
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
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' },
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
|
}
|
||||||
|
setUploading(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUpload(slot: string) {
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slot }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' });
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[slot];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to remove' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isUploadedFile = (key: string): boolean => {
|
||||||
|
const val = currentValue(key);
|
||||||
|
return val.startsWith('/api/admin/branding/');
|
||||||
|
};
|
||||||
|
|
||||||
|
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">Branding</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</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="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">Images & Logos</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)</p>
|
||||||
|
</div>
|
||||||
|
<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 items-center gap-2 min-w-0">
|
||||||
|
<label className="text-sm text-foreground whitespace-nowrap">{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">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||||
|
type="file"
|
||||||
|
accept={field.accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleUpload(field.key, file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||||
|
disabled={uploading === field.key}
|
||||||
|
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||||
|
title="Upload file"
|
||||||
|
>
|
||||||
|
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
{isUploadedFile(field.key) && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUpload(field.key)}
|
||||||
|
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
title="Remove uploaded file"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
||||||
|
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</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" />
|
||||||
|
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||||
|
<img
|
||||||
|
src={currentValue(field.key)}
|
||||||
|
alt={field.label}
|
||||||
|
className="max-h-6 max-w-[200px] object-contain"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">Company Information</h2>
|
||||||
|
</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 className="flex items-center gap-2 min-w-0">
|
||||||
|
<label className="text-sm text-foreground whitespace-nowrap">{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">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
{config[field.key]?.source === 'admin' && (
|
||||||
|
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Lock } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
export default function ChangePasswordPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setError('New password must be at least 8 characters.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('New passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
const res = await apiFetch('/api/admin/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSuccess(true);
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setTimeout(() => router.push('/admin'), 2000);
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setError(data.error || 'Failed to change password.');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Change Password</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Update your admin password.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">Current Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={e => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<p className="text-sm text-green-600">Password changed. Redirecting...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Changing...' : 'Change Password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
'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,
|
||||||
|
Activity,
|
||||||
|
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/telemetry', label: 'Telemetry', icon: Activity },
|
||||||
|
{ 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 [authError, setAuthError] = useState<string | 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') 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}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Shield } from 'lucide-react';
|
||||||
|
import { useConfig } from '@/hooks/use-config';
|
||||||
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
export default function AdminLoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || 'Login failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/admin');
|
||||||
|
} catch {
|
||||||
|
setError('Network error. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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" />
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring"
|
||||||
|
placeholder="Enter admin password"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !password}
|
||||||
|
className="w-full h-10 rounded-md bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 disabled:opacity-50 disabled:pointer-events-none transition-all duration-200 shadow-sm"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
export default function AdminLogsPage() {
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionFilter, setActionFilter] = useState('');
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
|
if (actionFilter) params.set('action', actionFilter);
|
||||||
|
|
||||||
|
const res = await apiFetch(`/api/admin/audit?${params}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setEntries(data.entries || []);
|
||||||
|
setTotal(data.total || 0);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [page, actionFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
onClick={fetchLogs}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<option value="">All actions</option>
|
||||||
|
<option value="admin.login">Login</option>
|
||||||
|
<option value="admin.logout">Logout</option>
|
||||||
|
<option value="admin.login_failed">Login Failed</option>
|
||||||
|
<option value="admin.login_blocked">Login Blocked</option>
|
||||||
|
<option value="admin.change-password">Password Change</option>
|
||||||
|
<option value="config.update">Config Update</option>
|
||||||
|
<option value="config.revert">Config Revert</option>
|
||||||
|
<option value="policy.update">Policy Update</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<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">Details</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-muted-foreground">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{loading && entries.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">Loading...</td>
|
||||||
|
</tr>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">No entries found</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
entries.map((entry, i) => (
|
||||||
|
<tr key={i} className="hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{new Date(entry.ts).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{entry.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
|
||||||
|
{formatDetail(entry.detail)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
|
||||||
|
{entry.ip}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetail(detail: Record<string, unknown>): string {
|
||||||
|
if (!detail || Object.keys(detail).length === 0) return '-';
|
||||||
|
if (detail.reason) return String(detail.reason);
|
||||||
|
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
||||||
|
if (detail.changes && Array.isArray(detail.changes)) {
|
||||||
|
return detail.changes.map((c: Record<string, unknown>) => `${c.key}`).join(', ');
|
||||||
|
}
|
||||||
|
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
|
||||||
|
return JSON.stringify(detail).slice(0, 100);
|
||||||
|
}
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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: data.extension.latestVersion || '1.0.0',
|
||||||
|
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: `"${data.extension.name}" installed${warnings}` });
|
||||||
|
setData(prev => prev ? { ...prev, installed: true } : prev);
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: body.error || 'Installation failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: '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;
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
|
{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 items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground truncate">{ext.name}</h1>
|
||||||
|
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
||||||
|
{data.installed && (
|
||||||
|
<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">
|
||||||
|
<Check className="w-3 h-3" /> Installed
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{data.installed ? (
|
||||||
|
<>
|
||||||
|
<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}
|
||||||
|
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 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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,366 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface Extension {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
type: 'plugin' | 'theme';
|
||||||
|
pluginType: string | null;
|
||||||
|
description: string;
|
||||||
|
permissions: string[];
|
||||||
|
tags: string[];
|
||||||
|
totalDownloads: number;
|
||||||
|
featured: boolean;
|
||||||
|
minAppVersion: string | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
installed: boolean;
|
||||||
|
author: {
|
||||||
|
displayName: string;
|
||||||
|
githubLogin: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
data: Extension[];
|
||||||
|
meta: {
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type TypeFilter = 'all' | 'plugin' | 'theme';
|
||||||
|
|
||||||
|
export default function AdminMarketplacePage() {
|
||||||
|
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [perPage] = useState(12);
|
||||||
|
const [installing, setInstalling] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchExtensions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query) params.set('q', query);
|
||||||
|
if (typeFilter !== 'all') params.set('type', typeFilter);
|
||||||
|
params.set('page', String(page));
|
||||||
|
params.set('perPage', String(perPage));
|
||||||
|
params.set('sort', 'newest');
|
||||||
|
|
||||||
|
const res = await apiFetch(`/api/admin/marketplace?${params}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setError(data.error || 'Failed to connect to extension directory');
|
||||||
|
setExtensions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: SearchResult = await res.json();
|
||||||
|
setExtensions(data.data || []);
|
||||||
|
setTotal(data.meta?.total || 0);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to connect to extension directory. Make sure it is running.');
|
||||||
|
setExtensions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [query, typeFilter, page, perPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchExtensions();
|
||||||
|
}, [fetchExtensions]);
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setQuery(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
async function handleInstall(ext: Extension) {
|
||||||
|
setInstalling(ext.slug);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/marketplace', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: ext.slug,
|
||||||
|
version: ext.latestVersion || '1.0.0',
|
||||||
|
type: ext.type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
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));
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||||
|
} finally {
|
||||||
|
setInstalling(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / perPage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Marketplace</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Browse and install plugins and themes from the BulwarkMail extension directory
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search extensions..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
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">
|
||||||
|
{(['all', 'plugin', 'theme'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => { setTypeFilter(t); setPage(1); }}
|
||||||
|
className={`h-8 px-3 rounded text-sm font-medium transition-colors ${
|
||||||
|
typeFilter === t
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === 'all' ? 'All' : t === 'plugin' ? 'Plugins' : 'Themes'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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" />
|
||||||
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Start the extension directory server on the configured port
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchExtensions}
|
||||||
|
className="mt-4 inline-flex items-center gap-2 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</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" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
|
||||||
|
</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" />
|
||||||
|
<p className="text-sm text-muted-foreground">No extensions found</p>
|
||||||
|
{query && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Try a different search term
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Extension Grid */}
|
||||||
|
{!loading && !error && extensions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{total} extension{total !== 1 ? 's' : ''} found
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{extensions.map((ext) => (
|
||||||
|
<ExtensionCard
|
||||||
|
key={ext.slug}
|
||||||
|
extension={ext}
|
||||||
|
installing={installing === ext.slug}
|
||||||
|
onInstall={() => handleInstall(ext)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExtensionCard({
|
||||||
|
extension,
|
||||||
|
installing,
|
||||||
|
onInstall,
|
||||||
|
}: {
|
||||||
|
extension: Extension;
|
||||||
|
installing: boolean;
|
||||||
|
onInstall: () => void;
|
||||||
|
}) {
|
||||||
|
const isPlugin = extension.type === 'plugin';
|
||||||
|
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||||
|
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
|
||||||
|
{/* Header */}
|
||||||
|
<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 ? (
|
||||||
|
<Puzzle className="w-5 h-5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<SwatchBook className="w-5 h-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</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 group-hover:underline">
|
||||||
|
{extension.name}
|
||||||
|
</span>
|
||||||
|
{extension.featured && (
|
||||||
|
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<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 ? (extension.pluginType || 'plugin') : 'theme'}
|
||||||
|
</span>
|
||||||
|
{extension.author && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
by {extension.author.displayName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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 => (
|
||||||
|
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer (download count + permissions) */}
|
||||||
|
<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">
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
{extension.totalDownloads.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{extension.permissions && extension.permissions.length > 0 && (
|
||||||
|
<span title={extension.permissions.join(', ')}>
|
||||||
|
{extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Quick install button (sits over the link, stops navigation) */}
|
||||||
|
<div className="px-4 pb-4 -mt-1">
|
||||||
|
{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">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
Installed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Quick install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||||
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface AdminStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
lastLogin: string | null;
|
||||||
|
passwordChangedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigData {
|
||||||
|
appName?: string;
|
||||||
|
jmapServerUrl?: string;
|
||||||
|
settingsSyncEnabled?: boolean;
|
||||||
|
stalwartFeaturesEnabled?: boolean;
|
||||||
|
oauthEnabled?: boolean;
|
||||||
|
devMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboardPage() {
|
||||||
|
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 [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(() => {
|
||||||
|
fetchDashboardData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchDashboardData() {
|
||||||
|
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'),
|
||||||
|
apiFetch('/api/admin/config'),
|
||||||
|
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());
|
||||||
|
if (auditRes.ok) {
|
||||||
|
const data = await auditRes.json();
|
||||||
|
setRecentActivity(data.entries || []);
|
||||||
|
}
|
||||||
|
let configData: ConfigData | null = null;
|
||||||
|
if (configRes.ok) {
|
||||||
|
configData = await configRes.json();
|
||||||
|
setConfig(configData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pluginRes?.ok) {
|
||||||
|
const plugins = await pluginRes.json();
|
||||||
|
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
|
||||||
|
}
|
||||||
|
if (themeRes?.ok) {
|
||||||
|
const themes = await themeRes.json();
|
||||||
|
setThemeCount(Array.isArray(themes) ? themes.length : 0);
|
||||||
|
}
|
||||||
|
if (policyRes?.ok) {
|
||||||
|
const policy = await policyRes.json();
|
||||||
|
const restrictionCount = policy.restrictions ? Object.keys(policy.restrictions).length : 0;
|
||||||
|
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 {
|
||||||
|
const jmapRes = await apiFetch('/api/config');
|
||||||
|
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||||
|
} catch {
|
||||||
|
setJmapHealth('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const w: string[] = [];
|
||||||
|
if (adminConfigRes.ok) {
|
||||||
|
const sources = await adminConfigRes.json();
|
||||||
|
setConfigSources(sources);
|
||||||
|
const sessionSecret = sources?.sessionSecret;
|
||||||
|
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
|
||||||
|
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
||||||
|
}
|
||||||
|
const adminPassword = sources?.adminPassword;
|
||||||
|
if (adminPassword?.value && adminPassword.source === 'env') {
|
||||||
|
w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setWarnings(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jmapUrl = config?.jmapServerUrl || '-';
|
||||||
|
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
|
||||||
|
|
||||||
|
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" />
|
||||||
|
<p className="text-sm text-warning">{msg}</p>
|
||||||
|
</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>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||||
|
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="JMAP Connection">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
|
||||||
|
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
|
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
|
||||||
|
}`} />
|
||||||
|
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Last Login">
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||||
|
</span>
|
||||||
|
</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 />
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Settings Sync" description="Synchronize user settings across devices">
|
||||||
|
<ToggleSwitch checked={!!config?.settingsSyncEnabled} onChange={() => {}} disabled />
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="OAuth" description="OAuth authentication provider">
|
||||||
|
<ToggleSwitch checked={!!config?.oauthEnabled} onChange={() => {}} disabled />
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Stalwart Integration" description="Stalwart mail server features">
|
||||||
|
<ToggleSwitch checked={config?.stalwartFeaturesEnabled !== false} onChange={() => {}} disabled />
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Accounts */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Extensions */}
|
||||||
|
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
||||||
|
<SettingItem label="Plugins">
|
||||||
|
<span className="text-sm text-foreground">{pluginCount}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Themes">
|
||||||
|
<span className="text-sm text-foreground">{themeCount}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Policy Rules">
|
||||||
|
<span className="text-sm text-foreground">{policyRuleCount}</span>
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<SettingsSection title="Recent Activity" description="Latest administrative actions">
|
||||||
|
{recentActivity.length === 0 ? (
|
||||||
|
<div className="py-4 text-sm text-muted-foreground">
|
||||||
|
No activity recorded yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
recentActivity.map((entry, i) => (
|
||||||
|
<SettingItem
|
||||||
|
key={i}
|
||||||
|
label={entry.action}
|
||||||
|
description={formatDetail(entry.detail) || undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>{entry.ip}</span>
|
||||||
|
<span>{new Date(entry.ts).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</SettingItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetail(detail: Record<string, unknown>): string {
|
||||||
|
if (!detail || Object.keys(detail).length === 0) return '';
|
||||||
|
if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`;
|
||||||
|
if (detail.reason) return String(detail.reason);
|
||||||
|
if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`;
|
||||||
|
return JSON.stringify(detail).slice(0, 80);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
interface ConfigField {
|
||||||
|
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: unknown;
|
||||||
|
placeholder?: string;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginConfig {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
type: string;
|
||||||
|
permissions: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
configSchema?: Record<string, ConfigField>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginConfigPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const pluginId = params.id as string;
|
||||||
|
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
|
||||||
|
const [config, setConfig] = useState<PluginConfig>({});
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pluginId]);
|
||||||
|
|
||||||
|
// Initialize form values from config + schema defaults when data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!plugin?.configSchema) return;
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
const stored = config[key];
|
||||||
|
if (stored !== undefined && stored !== null) {
|
||||||
|
initial[key] = String(stored);
|
||||||
|
} else if (field.default !== undefined) {
|
||||||
|
initial[key] = String(field.default);
|
||||||
|
} else {
|
||||||
|
initial[key] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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` });
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
|
} else {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, value }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(prev => ({ ...prev, [key]: value }));
|
||||||
|
} else {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(hasError
|
||||||
|
? { type: 'error', text: 'Some settings failed to save' }
|
||||||
|
: { type: 'success', text: 'Configuration saved' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save configuration' });
|
||||||
|
} finally {
|
||||||
|
setSaving(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 (!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">
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Back to Plugins
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = plugin.configSchema;
|
||||||
|
const hasSchema = schema && Object.keys(schema).length > 0;
|
||||||
|
|
||||||
|
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">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Puzzle className="w-5 h-5" />
|
||||||
|
{plugin.name} Configuration
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
v{plugin.version} by {plugin.author}
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSchema ? (
|
||||||
|
<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">Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-5">
|
||||||
|
{Object.entries(schema).map(([key, field]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="text-sm font-medium text-foreground block mb-1">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
{field.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === 'boolean' ? (
|
||||||
|
<select
|
||||||
|
value={formValues[key] ?? String(field.default ?? 'false')}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="true">Enabled</option>
|
||||||
|
<option value="false">Disabled</option>
|
||||||
|
</select>
|
||||||
|
) : field.type === 'select' && field.options ? (
|
||||||
|
<select
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">- Select -</option>
|
||||||
|
{field.options.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : field.type === 'secret' ? (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={revealSecrets[key] ? 'text' : 'password'}
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
||||||
|
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
|
||||||
|
>
|
||||||
|
{revealSecrets[key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder || ''}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
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" /> : null}
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-border rounded-lg p-8 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
interface PluginEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
forceEnabled?: boolean;
|
||||||
|
permissions: string[];
|
||||||
|
installedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPluginsPage() {
|
||||||
|
const [plugins, setPlugins] = useState<PluginEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||||
|
const [policyDirty, setPolicyDirty] = useState(false);
|
||||||
|
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
|
||||||
|
|
||||||
|
async function fetchPolicy() {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/policy');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPolicy(data);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePluginsEnabled() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePluginsUploadEnabled() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRequirePluginApproval() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePolicy() {
|
||||||
|
setSavingPolicy(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' });
|
||||||
|
setPolicyDirty(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save policy' });
|
||||||
|
} finally {
|
||||||
|
setSavingPolicy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlugins() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/plugins');
|
||||||
|
if (res.ok) setPlugins(await res.json());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
|
||||||
|
await fetchPlugins();
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Upload failed' });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlugin(id: string, enabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
|
...prev,
|
||||||
|
forceEnabledPlugins: forceEnabled
|
||||||
|
? [...current.filter(pid => pid !== id), id]
|
||||||
|
: current.filter(pid => pid !== id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceEnableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const disabled = plugins.filter(p => !p.enabled);
|
||||||
|
if (disabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All plugins are already enabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const p of disabled) {
|
||||||
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: p.id, enabled: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p));
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` });
|
||||||
|
} else {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceDisableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const enabled = plugins.filter(p => p.enabled);
|
||||||
|
if (enabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All plugins are already disabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const p of enabled) {
|
||||||
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: p.id, enabled: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` });
|
||||||
|
} else {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlugin(id: string, name: string) {
|
||||||
|
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
setMessage(null);
|
||||||
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setPlugins(prev => prev.filter(p => p.id !== id));
|
||||||
|
setMessage({ type: 'success', text: `Plugin "${name}" removed` });
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||||
|
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||||
|
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
{policyDirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSavePolicy}
|
||||||
|
disabled={savingPolicy}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save Policy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<label 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 cursor-pointer transition-all shadow-sm">
|
||||||
|
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||||
|
Upload Plugin
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
onChange={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Plugin Policy</h2>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<button onClick={togglePluginsEnabled}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? '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 ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between 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>
|
||||||
|
</div>
|
||||||
|
<button onClick={togglePluginsUploadEnabled}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? '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 ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between 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>
|
||||||
|
</div>
|
||||||
|
<button onClick={toggleRequirePluginApproval}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? '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 ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Force enable / disable all */}
|
||||||
|
{plugins.length > 0 && (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between 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>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Power className="w-3.5 h-3.5" />
|
||||||
|
Enable All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={forceDisableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<PowerOff className="w-3.5 h-3.5" />
|
||||||
|
Disable All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
<Package className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Deployed Plugins</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded plugins for all users</p>
|
||||||
|
</div>
|
||||||
|
{plugins.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<Package className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">No plugins installed</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Upload a plugin ZIP file to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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 className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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'}`}>
|
||||||
|
{plugin.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
{plugin.forceEnabled && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||||
|
<Lock className="w-3 h-3" /> Forced
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{plugin.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
{plugin.permissions.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-warning" />
|
||||||
|
<span className="text-xs text-warning">
|
||||||
|
Permissions: {plugin.permissions.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/plugins/${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
|
||||||
|
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||||
|
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
||||||
|
className={`p-2 rounded-md transition-colors ${plugin.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
{plugin.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
|
||||||
|
title={plugin.enabled ? 'Disable' : 'Enable'}
|
||||||
|
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Power className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deletePlugin(plugin.id, plugin.name)}
|
||||||
|
title="Remove"
|
||||||
|
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, Loader2, Lock } from 'lucide-react';
|
||||||
|
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 }>> = {
|
||||||
|
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
||||||
|
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
|
||||||
|
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' },
|
||||||
|
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||||
|
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||||
|
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESTRICTABLE_SETTINGS = [
|
||||||
|
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
|
||||||
|
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||||
|
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||||
|
{ 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: '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' },
|
||||||
|
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
||||||
|
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
|
||||||
|
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
||||||
|
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
||||||
|
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
||||||
|
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
|
||||||
|
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminPolicyPage() {
|
||||||
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPolicy(); }, []);
|
||||||
|
|
||||||
|
async function fetchPolicy() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/policy');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPolicy(data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFeature(key: keyof FeatureGates) {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, [key]: !prev.features[key] },
|
||||||
|
}));
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLocked(settingKey: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const existing = prev.restrictions[settingKey] || {};
|
||||||
|
const newRestrictions = { ...prev.restrictions };
|
||||||
|
if (existing.locked) {
|
||||||
|
delete newRestrictions[settingKey];
|
||||||
|
} else {
|
||||||
|
newRestrictions[settingKey] = { ...existing, locked: true };
|
||||||
|
}
|
||||||
|
return { ...prev, restrictions: newRestrictions };
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHidden(settingKey: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const existing = prev.restrictions[settingKey] || {};
|
||||||
|
const newRestrictions = { ...prev.restrictions };
|
||||||
|
newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden };
|
||||||
|
if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) {
|
||||||
|
delete newRestrictions[settingKey];
|
||||||
|
}
|
||||||
|
return { ...prev, restrictions: newRestrictions };
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' });
|
||||||
|
setDirty(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
{dirty && (
|
||||||
|
<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 policy
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[])
|
||||||
|
.filter(key => !EXCLUDED_FEATURE_GATES.includes(key))
|
||||||
|
.map(key => {
|
||||||
|
const meta = FEATURE_GATE_LABELS[key];
|
||||||
|
if (!meta) return null;
|
||||||
|
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>
|
||||||
|
<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'}`}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">{category}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{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">
|
||||||
|
<span className="text-sm text-foreground">{setting.label}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<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" />
|
||||||
|
<Lock className="w-3 h-3" /> Lock
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
|
||||||
|
className="rounded border-input" />
|
||||||
|
Hide
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface ConfigEntry {
|
||||||
|
value: unknown;
|
||||||
|
source: 'admin' | 'env' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminSettingsPage() {
|
||||||
|
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: 'Settings saved. Changes take effect on next page load.' });
|
||||||
|
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();
|
||||||
|
setMessage({ type: 'success', text: `${key} reverted to default` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">Server Settings</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">General server 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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" />
|
||||||
|
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||||
|
<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> External JMAP servers must include this domain in their CORS <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 requests from the browser will be blocked.
|
||||||
|
</p>
|
||||||
|
</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} />
|
||||||
|
<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="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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsSection({ 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 TextSetting({ label, configKey, value, source, onChange, onRevert, placeholder }: {
|
||||||
|
label: string; configKey: string; value: string; source?: string;
|
||||||
|
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="flex items-center gap-2 min-w-0">
|
||||||
|
<label className="text-sm text-foreground whitespace-nowrap">{label}</label>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleSetting({ label, description, configKey, value, source, onChange, onRevert }: {
|
||||||
|
label: string; description?: string; configKey: string; value: boolean; source?: string;
|
||||||
|
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="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 to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSetting({ label, configKey, value, source, options, onChange, onRevert }: {
|
||||||
|
label: string; configKey: string; value: string; source?: string; options: string[];
|
||||||
|
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">
|
||||||
|
<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(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
|
</select>
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 default function AdminTelemetryPage() {
|
||||||
|
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="max-w-3xl mx-auto p-6 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 items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<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 gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={endpointDraft}
|
||||||
|
onChange={(e) => setEndpointDraft(e.target.value)}
|
||||||
|
placeholder={status.defaultEndpoint}
|
||||||
|
className="flex-1 px-3 py-1.5 rounded-md border bg-background"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
|
||||||
|
onClick={() => void saveEndpoint()}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" /> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<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="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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
||||||
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
const BUILTIN_THEME_OPTIONS = [
|
||||||
|
{ id: 'builtin-nord', name: 'Nord' },
|
||||||
|
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
||||||
|
{ id: 'builtin-solarized', name: 'Solarized' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ThemeEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
variants: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
forceEnabled?: boolean;
|
||||||
|
installedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminThemesPage() {
|
||||||
|
const [themes, setThemes] = useState<ThemeEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||||
|
const [policyDirty, setPolicyDirty] = useState(false);
|
||||||
|
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { fetchThemes(); fetchPolicy(); }, []);
|
||||||
|
|
||||||
|
async function fetchPolicy() {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/policy');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPolicy({
|
||||||
|
...data,
|
||||||
|
themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleThemesEnabled() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, themesEnabled: !prev.features.themesEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUserThemeUploads() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBuiltinTheme(themeId: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
|
||||||
|
const isDisabled = disabled.includes(themeId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
themePolicy: {
|
||||||
|
...DEFAULT_THEME_POLICY,
|
||||||
|
...prev.themePolicy,
|
||||||
|
disabledBuiltinThemes: isDisabled
|
||||||
|
? disabled.filter((id: string) => id !== themeId)
|
||||||
|
: [...disabled, themeId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAdminTheme(themeId: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const disabled = prev.themePolicy?.disabledThemes || [];
|
||||||
|
const isDisabled = disabled.includes(themeId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
themePolicy: {
|
||||||
|
...DEFAULT_THEME_POLICY,
|
||||||
|
...prev.themePolicy,
|
||||||
|
disabledThemes: isDisabled
|
||||||
|
? disabled.filter((id: string) => id !== themeId)
|
||||||
|
: [...disabled, themeId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDefaultTheme(themeId: string | null) {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
themePolicy: {
|
||||||
|
...DEFAULT_THEME_POLICY,
|
||||||
|
...prev.themePolicy,
|
||||||
|
defaultThemeId: themeId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePolicy() {
|
||||||
|
setSavingPolicy(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Theme policy saved. Users will see changes on next login.' });
|
||||||
|
setPolicyDirty(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save policy' });
|
||||||
|
} finally {
|
||||||
|
setSavingPolicy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchThemes() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/themes');
|
||||||
|
if (res.ok) setThemes(await res.json());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `Theme "${data.theme.name}" installed${warnings}` });
|
||||||
|
await fetchThemes();
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Upload failed' });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTheme(id: string, enabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t));
|
||||||
|
setPolicy(prev => {
|
||||||
|
const current = prev.forceEnabledThemes || [];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
forceEnabledThemes: forceEnabled
|
||||||
|
? [...current.filter(tid => tid !== id), id]
|
||||||
|
: current.filter(tid => tid !== id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceEnableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const disabled = themes.filter(t => !t.enabled);
|
||||||
|
if (disabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All themes are already enabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const t of disabled) {
|
||||||
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: t.id, enabled: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` });
|
||||||
|
} else {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceDisableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const enabled = themes.filter(t => t.enabled);
|
||||||
|
if (enabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All themes are already disabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const t of enabled) {
|
||||||
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: t.id, enabled: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` });
|
||||||
|
} else {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTheme(id: string, name: string) {
|
||||||
|
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
setMessage(null);
|
||||||
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setThemes(prev => prev.filter(t => t.id !== id));
|
||||||
|
setMessage({ type: 'success', text: `Theme "${name}" removed` });
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themesEnabled = policy.features.themesEnabled ?? true;
|
||||||
|
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
{policyDirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSavePolicy}
|
||||||
|
disabled={savingPolicy}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save Policy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<label 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 cursor-pointer transition-all shadow-sm">
|
||||||
|
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||||
|
Upload Theme
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
onChange={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Theme Policy</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Control theme availability and defaults for users</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{/* Master toggle */}
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<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'}`}>
|
||||||
|
<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>
|
||||||
|
<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'}`}>
|
||||||
|
<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>
|
||||||
|
<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">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Power className="w-3.5 h-3.5" />
|
||||||
|
Enable All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={forceDisableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<PowerOff className="w-3.5 h-3.5" />
|
||||||
|
Disable All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Default Theme */}
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<option value="">System Default</option>
|
||||||
|
<optgroup label="Built-in">
|
||||||
|
{BUILTIN_THEME_OPTIONS
|
||||||
|
.filter(t => !(policy.themePolicy?.disabledBuiltinThemes || []).includes(t.id))
|
||||||
|
.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
{themes.length > 0 && (
|
||||||
|
<optgroup label="Admin-deployed">
|
||||||
|
{themes
|
||||||
|
.filter(t => !(policy.themePolicy?.disabledThemes || []).includes(t.id))
|
||||||
|
.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</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">
|
||||||
|
{BUILTIN_THEME_OPTIONS.map(theme => {
|
||||||
|
const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id);
|
||||||
|
return (
|
||||||
|
<div key={theme.id} className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm text-foreground">{theme.name}</span>
|
||||||
|
<button onClick={() => toggleBuiltinTheme(theme.id)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? '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 ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{themes.map(theme => {
|
||||||
|
const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id);
|
||||||
|
return (
|
||||||
|
<div key={theme.id} className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm text-foreground">{theme.name}</span>
|
||||||
|
<button onClick={() => toggleAdminTheme(theme.id)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? '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 ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
<Palette className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Deployed Themes</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded themes available to all users</p>
|
||||||
|
</div>
|
||||||
|
{themes.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<Palette className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">No themes installed</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Upload a theme ZIP file to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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 className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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'}`}>
|
||||||
|
{theme.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
{theme.forceEnabled && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||||
|
<Lock className="w-3 h-3" /> Forced
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{theme.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{theme.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
by {theme.author} · {theme.variants.join(', ')} · installed {new Date(theme.installedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleForceEnabled(theme.id, !theme.forceEnabled)}
|
||||||
|
title={theme.forceEnabled ? 'Remove force-enable (users can deactivate)' : 'Force enable (users cannot deactivate)'}
|
||||||
|
className={`p-2 rounded-md transition-colors ${theme.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
{theme.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleTheme(theme.id, !theme.enabled)}
|
||||||
|
title={theme.enabled ? 'Disable' : 'Enable'}
|
||||||
|
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Power className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteTheme(theme.id, theme.name)}
|
||||||
|
title="Remove"
|
||||||
|
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
logger.warn('Stalwart auth info failed', { status: response.status });
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to fetch auth info', details: text },
|
|
||||||
{ 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,72 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
logger.warn('Stalwart crypto info failed', { status: response.status });
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to fetch crypto info', details: text },
|
|
||||||
{ 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,83 +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, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
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
|
|
||||||
if (creds.hasSessionCookie) {
|
|
||||||
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,81 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
logger.warn('Stalwart principal fetch failed', { status: response.status });
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to fetch principal', details: text },
|
|
||||||
{ 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
import { readAuditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/audit - Get paginated audit log entries (admin-protected)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
|
||||||
|
const limit = Math.min(200, Math.max(1, parseInt(request.nextUrl.searchParams.get('limit') || '50', 10)));
|
||||||
|
const action = request.nextUrl.searchParams.get('action') || undefined;
|
||||||
|
|
||||||
|
const { entries, total } = await readAuditLog(page, limit, action);
|
||||||
|
|
||||||
|
return NextResponse.json({ entries, total, page, limit }, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Audit log read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
||||||
|
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/branding/[filename] - Serve uploaded branding images
|
||||||
|
*
|
||||||
|
* This endpoint is public (no admin auth) so browsers can load images.
|
||||||
|
* Only files in the branding directory are served; directory traversal is prevented.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ filename: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { filename } = await params;
|
||||||
|
|
||||||
|
// Sanitize: only allow basename, no path separators
|
||||||
|
const safe = path.basename(filename);
|
||||||
|
if (safe !== filename || filename.includes('..')) {
|
||||||
|
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(safe).toLowerCase();
|
||||||
|
const contentType = MIME_TYPES[ext];
|
||||||
|
if (!contentType) {
|
||||||
|
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(BRANDING_DIR, safe);
|
||||||
|
|
||||||
|
// Ensure resolved path is still within BRANDING_DIR
|
||||||
|
const resolved = path.resolve(filePath);
|
||||||
|
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStat = await stat(resolved).catch(() => null);
|
||||||
|
if (!fileStat || !fileStat.isFile()) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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 { 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');
|
||||||
|
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||||
|
const ALLOWED_MIME_TYPES = new Set([
|
||||||
|
'image/svg+xml',
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/webp',
|
||||||
|
'image/x-icon',
|
||||||
|
'image/vnd.microsoft.icon',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Slots that correspond to branding config keys */
|
||||||
|
const VALID_SLOTS = new Set([
|
||||||
|
'faviconUrl',
|
||||||
|
'appLogoLightUrl',
|
||||||
|
'appLogoDarkUrl',
|
||||||
|
'loginLogoLightUrl',
|
||||||
|
'loginLogoDarkUrl',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
// Strip directory traversal, keep only safe chars
|
||||||
|
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/branding - Upload a branding image file
|
||||||
|
*
|
||||||
|
* Expects multipart/form-data with:
|
||||||
|
* - file: the image file
|
||||||
|
* - slot: which branding field this is for (e.g. "faviconUrl")
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
const slot = formData.get('slot') as string | null;
|
||||||
|
|
||||||
|
if (!file || !slot) {
|
||||||
|
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_SLOTS.has(slot)) {
|
||||||
|
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine extension from mime type
|
||||||
|
const extMap: Record<string, string> = {
|
||||||
|
'image/svg+xml': '.svg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'image/x-icon': '.ico',
|
||||||
|
'image/vnd.microsoft.icon': '.ico',
|
||||||
|
};
|
||||||
|
const ext = extMap[file.type] || '.png';
|
||||||
|
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||||
|
const filePath = path.join(BRANDING_DIR, safeName);
|
||||||
|
|
||||||
|
// Ensure branding directory exists
|
||||||
|
if (!existsSync(BRANDING_DIR)) {
|
||||||
|
await mkdir(BRANDING_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file to disk
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
// Update config to point to the served URL
|
||||||
|
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||||
|
|
||||||
|
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Branding upload error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||||
|
*
|
||||||
|
* Expects JSON body: { slot: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { slot } = await request.json();
|
||||||
|
|
||||||
|
if (!slot || !VALID_SLOTS.has(slot)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and remove matching files for this slot
|
||||||
|
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||||
|
let removed = false;
|
||||||
|
for (const ext of possibleExts) {
|
||||||
|
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
await unlink(filePath);
|
||||||
|
removed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the config override so it falls back to default/env
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
await configManager.removeAdminOverride(slot);
|
||||||
|
|
||||||
|
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Branding delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { changeAdminPassword } from '@/lib/admin/password';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/change-password - Change admin password
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { currentPassword, newPassword } = await request.json();
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword || typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Both current and new password are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
return NextResponse.json({ error: 'New password must be at least 8 characters' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await changeAdminPassword(currentPassword, newPassword);
|
||||||
|
if (!success) {
|
||||||
|
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('admin.change-password', {}, ip);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin change password error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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 { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/config - Get full config with sources (admin-protected)
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const config = configManager.getAllWithSources();
|
||||||
|
|
||||||
|
return NextResponse.json(config, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin config read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/admin/config - Update config overrides (admin-protected)
|
||||||
|
*/
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const updates = await request.json();
|
||||||
|
|
||||||
|
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
||||||
|
return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate keys
|
||||||
|
const validKeys = Object.keys(CONFIG_ENV_MAP);
|
||||||
|
const invalidKeys = Object.keys(updates).filter(k => !validKeys.includes(k));
|
||||||
|
if (invalidKeys.length > 0) {
|
||||||
|
return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get old values for audit
|
||||||
|
const oldValues: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(updates)) {
|
||||||
|
oldValues[key] = configManager.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.setAdminConfig(updates);
|
||||||
|
await auditLog('config.update', { changes: Object.keys(updates).map(k => ({ key: k, old: oldValues[k], new: updates[k] })) }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin config update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/config - Remove admin override for a key (revert to env/default)
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { key } = await request.json();
|
||||||
|
|
||||||
|
if (!key || typeof key !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Key is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CONFIG_ENV_MAP[key]) {
|
||||||
|
return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldValue = configManager.get(key);
|
||||||
|
await configManager.removeAdminOverride(key);
|
||||||
|
await auditLog('config.revert', { key, oldValue }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin config revert error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || '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();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
// 1. Extension metadata + screenshots + theme previews from the directory
|
||||||
|
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
|
||||||
|
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)}`,
|
||||||
|
DIRECTORY_URL,
|
||||||
|
);
|
||||||
|
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 installed = type === 'theme'
|
||||||
|
? themeRegistry.themes.some((t) => t.id === slug)
|
||||||
|
: pluginRegistry.plugins.some((p) => p.id === slug);
|
||||||
|
|
||||||
|
// 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}`, DIRECTORY_URL).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,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{ 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
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 {
|
||||||
|
savePlugin,
|
||||||
|
saveTheme,
|
||||||
|
getPluginRegistry,
|
||||||
|
getThemeRegistry,
|
||||||
|
type ServerPlugin,
|
||||||
|
type ServerTheme,
|
||||||
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import {
|
||||||
|
sanitizeFrameOrigins,
|
||||||
|
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';
|
||||||
|
|
||||||
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||||
|
* Proxies to the extension directory API
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { searchParams } = request.nextUrl;
|
||||||
|
const url = new URL('/api/v1/extensions', DIRECTORY_URL);
|
||||||
|
|
||||||
|
// Forward all search params
|
||||||
|
for (const [key, value] of searchParams.entries()) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch from extension directory' },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Enrich with install status
|
||||||
|
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||||
|
getPluginRegistry(),
|
||||||
|
getThemeRegistry(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
|
||||||
|
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
|
||||||
|
|
||||||
|
if (data.data) {
|
||||||
|
data.data = data.data.map((ext: Record<string, unknown>) => ({
|
||||||
|
...ext,
|
||||||
|
installed: ext.type === 'theme'
|
||||||
|
? installedThemes.has(ext.slug as string)
|
||||||
|
: installedPlugins.has(ext.slug as string),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Marketplace search error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Failed to connect to extension directory' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/marketplace - Install an extension from the directory
|
||||||
|
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { slug, version, type } = await request.json();
|
||||||
|
|
||||||
|
if (!slug || !version || !type) {
|
||||||
|
return NextResponse.json({ error: 'Missing slug, version, or type' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type !== 'plugin' && type !== 'theme') {
|
||||||
|
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the bundle from the directory
|
||||||
|
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL);
|
||||||
|
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bundleRes.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Failed to download bundle: ${bundleRes.status}` },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await bundleRes.arrayBuffer();
|
||||||
|
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
|
||||||
|
|
||||||
|
if (buffer.byteLength > maxSize) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the ZIP
|
||||||
|
let zip: JSZip;
|
||||||
|
try {
|
||||||
|
zip = await JSZip.loadAsync(buffer);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid ZIP file from directory' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root directory
|
||||||
|
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 + '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read manifest
|
||||||
|
const manifestFile = zip.file(root + 'manifest.json');
|
||||||
|
if (!manifestFile) {
|
||||||
|
return NextResponse.json({ error: 'Bundle missing manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid manifest.json in bundle' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (type === 'theme') {
|
||||||
|
// Read theme.css
|
||||||
|
const cssFile = zip.file(root + 'theme.css');
|
||||||
|
if (!cssFile) {
|
||||||
|
return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let css = await cssFile.async('string');
|
||||||
|
|
||||||
|
// Validate and sanitize CSS
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const safety = validateThemeCSSSafety(css);
|
||||||
|
if (!safety.valid) {
|
||||||
|
const sanitized = sanitizeThemeCSS(css);
|
||||||
|
css = sanitized.css;
|
||||||
|
warnings.push(...sanitized.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme: ServerTheme = {
|
||||||
|
id: (manifest.id as string) || slug,
|
||||||
|
name: (manifest.name as string) || slug,
|
||||||
|
version: (manifest.version as string) || version,
|
||||||
|
author: (manifest.author as string) || 'Unknown',
|
||||||
|
description: (manifest.description as string) || '',
|
||||||
|
variants: (manifest.variants as string[]) || ['light', 'dark'],
|
||||||
|
enabled: true,
|
||||||
|
installedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveTheme(theme, css);
|
||||||
|
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, theme, warnings });
|
||||||
|
} else {
|
||||||
|
// Plugin installation
|
||||||
|
// Read entrypoint JS
|
||||||
|
const entrypoint = (manifest.entrypoint as string) || 'index.js';
|
||||||
|
const jsFile = zip.file(root + entrypoint);
|
||||||
|
if (!jsFile) {
|
||||||
|
return NextResponse.json({ error: `Bundle missing entrypoint: ${entrypoint}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await jsFile.async('string');
|
||||||
|
|
||||||
|
// Block plugins with dangerous JS patterns
|
||||||
|
const DANGEROUS_JS_PATTERNS = [
|
||||||
|
{ pattern: /\beval\s*\(/g, label: 'eval()' },
|
||||||
|
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
|
||||||
|
{ pattern: /document\.cookie/g, label: 'document.cookie' },
|
||||||
|
{ pattern: /document\.write/g, label: 'document.write' },
|
||||||
|
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
|
||||||
|
];
|
||||||
|
const dangerousFindings: string[] = [];
|
||||||
|
for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
|
||||||
|
if (pattern.test(code)) dangerousFindings.push(label);
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
}
|
||||||
|
if (dangerousFindings.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate permissions
|
||||||
|
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
||||||
|
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||||
|
const unknownPerms = permissions.filter(p => !validPerms.has(p));
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (unknownPerms.length > 0) {
|
||||||
|
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 plugin: ServerPlugin = {
|
||||||
|
id: (manifest.id as string) || slug,
|
||||||
|
name: (manifest.name as string) || slug,
|
||||||
|
version: (manifest.version as string) || version,
|
||||||
|
author: (manifest.author as string) || 'Unknown',
|
||||||
|
description: (manifest.description as string) || '',
|
||||||
|
type: (manifest.type as string) || 'hook',
|
||||||
|
permissions,
|
||||||
|
entrypoint,
|
||||||
|
enabled: true,
|
||||||
|
installedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...(declaredFrameOrigins.length > 0
|
||||||
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await savePlugin(plugin, code);
|
||||||
|
invalidateFrameOriginsCache();
|
||||||
|
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, plugin, warnings });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Installation failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
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,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||||
|
*
|
||||||
|
* Public endpoint so the client-side plugin loader can fetch bundles.
|
||||||
|
* Only serves plugins that exist in the registry and are enabled.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Validate ID format to prevent path traversal
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await getPlugin(id);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin.enabled) {
|
||||||
|
return NextResponse.json({ error: 'Plugin is disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await getPluginBundle(id);
|
||||||
|
if (!code) {
|
||||||
|
return NextResponse.json({ error: 'Bundle not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(code, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/javascript; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
|
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||||
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/plugins/[id]/config - Read plugin config
|
||||||
|
*
|
||||||
|
* - 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,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminAuth = await requireAdminAuth();
|
||||||
|
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 getPlugin(id);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getPluginConfig(id);
|
||||||
|
|
||||||
|
let response: Record<string, unknown> = config;
|
||||||
|
if (!isAdmin && plugin.configSchema) {
|
||||||
|
response = {};
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
const field = plugin.configSchema[key];
|
||||||
|
if (field?.type === 'secret') continue;
|
||||||
|
response[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(response, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/plugins/[id]/config - Set a config key
|
||||||
|
*
|
||||||
|
* Body: { key: string, value: unknown }
|
||||||
|
* Requires admin authentication (checked via admin session).
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await getPlugin(id);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { key?: string; value?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.key || typeof body.key !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'key is required and must be a string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key format (alphanumeric, hyphens, underscores, dots)
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(body.key)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setPluginConfig(id, body.key, body.value);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/plugins/[id]/config - Delete a config key
|
||||||
|
*
|
||||||
|
* Body: { key: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { key?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.key || typeof body.key !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'key is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deletePluginConfigKey(id, body.key);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
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 {
|
||||||
|
getPluginRegistry,
|
||||||
|
savePlugin,
|
||||||
|
deletePlugin as removePlugin,
|
||||||
|
type ServerPlugin,
|
||||||
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import {
|
||||||
|
sanitizeFrameOrigins,
|
||||||
|
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
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||||
|
|
||||||
|
const SUSPICIOUS_JS_PATTERNS = [
|
||||||
|
{ pattern: /\beval\s*\(/g, label: 'eval()' },
|
||||||
|
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
|
||||||
|
{ pattern: /document\.cookie/g, label: 'document.cookie' },
|
||||||
|
{ pattern: /document\.write/g, label: 'document.write' },
|
||||||
|
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/plugins - List all admin-managed plugins
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const registry = await getPluginRegistry();
|
||||||
|
return NextResponse.json(registry.plugins, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/plugins - Upload and install a plugin ZIP
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_PLUGIN_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and validate ZIP
|
||||||
|
let zip: JSZip;
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
zip = await JSZip.loadAsync(buffer);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root
|
||||||
|
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 + '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read manifest
|
||||||
|
const manifestFile = zip.file(root + 'manifest.json');
|
||||||
|
if (!manifestFile) {
|
||||||
|
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate manifest
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
|
||||||
|
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
|
||||||
|
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
|
||||||
|
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
|
||||||
|
if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"');
|
||||||
|
|
||||||
|
const validTypes = ['ui-extension', 'sidebar-app', 'hook'];
|
||||||
|
if (!validTypes.includes(manifest.type as string)) {
|
||||||
|
errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
|
||||||
|
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.permissions && Array.isArray(manifest.permissions)) {
|
||||||
|
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||||
|
const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p));
|
||||||
|
if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file extensions
|
||||||
|
for (const [filePath, entry] of Object.entries(zip.files)) {
|
||||||
|
if (entry.dir) continue;
|
||||||
|
const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : '';
|
||||||
|
if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) {
|
||||||
|
errors.push(`Disallowed file type: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read entrypoint code
|
||||||
|
const entryFile = zip.file(root + (manifest.entrypoint as string));
|
||||||
|
if (!entryFile) {
|
||||||
|
return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
const code = await entryFile.async('string');
|
||||||
|
|
||||||
|
// Security: block plugins containing dangerous JS patterns
|
||||||
|
const warnings: string[] = [];
|
||||||
|
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||||
|
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
}
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const plugin: ServerPlugin = {
|
||||||
|
id: manifest.id as string,
|
||||||
|
name: manifest.name as string,
|
||||||
|
version: manifest.version as string,
|
||||||
|
author: manifest.author as string,
|
||||||
|
description: (manifest.description as string) || '',
|
||||||
|
type: manifest.type as string,
|
||||||
|
permissions: (manifest.permissions as string[]) || [],
|
||||||
|
entrypoint: manifest.entrypoint as string,
|
||||||
|
enabled: true,
|
||||||
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
|
: {}),
|
||||||
|
...(declaredFrameOrigins.length > 0
|
||||||
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
|
: {}),
|
||||||
|
installedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await savePlugin(plugin, code);
|
||||||
|
invalidateFrameOriginsCache();
|
||||||
|
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ plugin });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/admin/plugins - Update plugin metadata (enable/disable)
|
||||||
|
* Body: { id: string, enabled: boolean }
|
||||||
|
*/
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { id, enabled, forceEnabled } = await request.json();
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
|
||||||
|
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
|
||||||
|
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||||
|
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||||
|
|
||||||
|
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
||||||
|
const updated = await updatePluginMeta(id, updates);
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/plugins - Remove a plugin
|
||||||
|
* Body: { id: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { id } = await request.json();
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await removePlugin(id);
|
||||||
|
if (!removed) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateFrameOriginsCache();
|
||||||
|
await auditLog('plugin.delete', { id }, ip);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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 { logger } from '@/lib/logger';
|
||||||
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this)
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const policy = configManager.getPolicy();
|
||||||
|
return NextResponse.json(policy, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/policy - Update settings policy (admin-protected)
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const policy = await request.json() as SettingsPolicy;
|
||||||
|
|
||||||
|
if (!policy || typeof policy !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'Invalid policy object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (policy.restrictions && typeof policy.restrictions !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'restrictions must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (policy.features && typeof policy.features !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'features must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (policy.themePolicy && typeof policy.themePolicy !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'themePolicy must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.setPolicy(policy);
|
||||||
|
await auditLog('policy.update', { restrictionCount: Object.keys(policy.restrictions || {}).length }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Policy update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
try {
|
||||||
|
const auth = await requireAdminAuth();
|
||||||
|
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();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/themes/[id]/css - Serve theme CSS to clients
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Validate ID format
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify theme exists and is enabled
|
||||||
|
const registry = await getThemeRegistry();
|
||||||
|
const theme = registry.themes.find(t => t.id === id);
|
||||||
|
if (!theme) {
|
||||||
|
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!theme.enabled) {
|
||||||
|
return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const css = await getThemeCSS(id);
|
||||||
|
if (!css) {
|
||||||
|
return NextResponse.json({ error: 'Theme CSS not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(css, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme CSS serve error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
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 {
|
||||||
|
getThemeRegistry,
|
||||||
|
saveTheme,
|
||||||
|
deleteTheme as removeTheme,
|
||||||
|
type ServerTheme,
|
||||||
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||||
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/themes - List all admin-managed themes
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const registry = await getThemeRegistry();
|
||||||
|
return NextResponse.json(registry.themes, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme list error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/themes - Upload and install a theme ZIP
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_THEME_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'Theme ZIP exceeds 1 MB size limit' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and validate ZIP
|
||||||
|
let zip: JSZip;
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
zip = await JSZip.loadAsync(buffer);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root
|
||||||
|
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 + '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read manifest
|
||||||
|
const manifestFile = zip.file(root + 'manifest.json');
|
||||||
|
if (!manifestFile) {
|
||||||
|
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate manifest
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
|
||||||
|
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
|
||||||
|
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
|
||||||
|
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
|
||||||
|
|
||||||
|
if (manifest.type !== 'theme') {
|
||||||
|
errors.push(`Expected type "theme", got "${manifest.type}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
|
||||||
|
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) {
|
||||||
|
errors.push('Missing or empty "variants" array');
|
||||||
|
} else {
|
||||||
|
const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark');
|
||||||
|
if (!valid) errors.push('Variants must be "light" or "dark"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read theme.css
|
||||||
|
const cssFile = zip.file(root + 'theme.css');
|
||||||
|
if (!cssFile) {
|
||||||
|
return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let css = await cssFile.async('string');
|
||||||
|
|
||||||
|
// Validate and sanitize CSS
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const safety = validateThemeCSSSafety(css);
|
||||||
|
if (!safety.valid) {
|
||||||
|
const sanitized = sanitizeThemeCSS(css);
|
||||||
|
css = sanitized.css;
|
||||||
|
warnings.push(...sanitized.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const theme: ServerTheme = {
|
||||||
|
id: manifest.id as string,
|
||||||
|
name: manifest.name as string,
|
||||||
|
version: manifest.version as string,
|
||||||
|
author: manifest.author as string,
|
||||||
|
description: (manifest.description as string) || '',
|
||||||
|
variants: manifest.variants as string[],
|
||||||
|
enabled: true,
|
||||||
|
installedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveTheme(theme, css);
|
||||||
|
await auditLog('theme.install', { id: theme.id, name: theme.name, version: theme.version, warnings }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ theme, warnings });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/admin/themes - Update theme metadata (enable/disable)
|
||||||
|
* Body: { id: string, enabled: boolean }
|
||||||
|
*/
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { id, enabled, forceEnabled } = await request.json();
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
|
||||||
|
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
|
||||||
|
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||||
|
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||||
|
|
||||||
|
const { updateThemeMeta } = await import('@/lib/admin/plugin-registry');
|
||||||
|
const updated = await updateThemeMeta(id, updates);
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('theme.update', { id, ...updates }, ip);
|
||||||
|
return NextResponse.json({ theme: updated });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/themes - Remove a theme
|
||||||
|
* Body: { id: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { id } = await request.json();
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await removeTheme(id);
|
||||||
|
if (!removed) {
|
||||||
|
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('theme.delete', { id }, ip);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
-14
@@ -2,42 +2,74 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
|
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 {
|
||||||
|
clearStalwartAuthContextInStore,
|
||||||
|
setStalwartAuthContextInStore,
|
||||||
|
} from '@/lib/stalwart/auth-context';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
httpOnly: true,
|
...getCookieOptions(),
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax' as const,
|
|
||||||
path: '/',
|
|
||||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
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;
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
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 });
|
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { serverUrl, username, password } = await request.json();
|
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
||||||
if (!serverUrl || !username || !password) {
|
if (!serverUrl || !username || !password) {
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = encryptSession(serverUrl, username, password);
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
|
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
||||||
|
const token = encryptSession(normalizedServerUrl, username, password);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(SESSION_COOKIE, token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||||
|
serverUrl: normalizedServerUrl,
|
||||||
|
username,
|
||||||
|
authHeader,
|
||||||
|
});
|
||||||
|
|
||||||
|
void recordLogin(username, normalizedServerUrl);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
const token = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||||
@@ -45,10 +77,67 @@ export async function GET() {
|
|||||||
|
|
||||||
const credentials = decryptSession(token);
|
const credentials = decryptSession(token);
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
cookieStore.delete(SESSION_COOKIE);
|
cookieStore.delete(cookieName);
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||||
|
serverUrl: credentials.serverUrl,
|
||||||
|
username: credentials.username,
|
||||||
|
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
|
||||||
|
const { serverUrl, username } = credentials;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ serverUrl, username },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT - retrieve full credentials (including password) for session restoration.
|
||||||
|
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
||||||
|
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Require all Sec-Fetch-* headers to match a same-origin fetch() call.
|
||||||
|
// Browsers set these automatically and they cannot be overridden by JS.
|
||||||
|
const secFetchSite = request.headers.get('sec-fetch-site');
|
||||||
|
const secFetchMode = request.headers.get('sec-fetch-mode');
|
||||||
|
const secFetchDest = request.headers.get('sec-fetch-dest');
|
||||||
|
if (secFetchSite !== 'same-origin' || secFetchMode !== 'cors' || secFetchDest !== 'empty') {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = decryptSession(token);
|
||||||
|
if (!credentials) {
|
||||||
|
cookieStore.delete(cookieName);
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||||
|
serverUrl: credentials.serverUrl,
|
||||||
|
username: credentials.username,
|
||||||
|
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(credentials, {
|
return NextResponse.json(credentials, {
|
||||||
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
||||||
});
|
});
|
||||||
@@ -58,10 +147,23 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.delete(SESSION_COOKIE);
|
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||||
|
|
||||||
|
if (all) {
|
||||||
|
// Delete all session cookies (slots 0-4)
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
cookieStore.delete(sessionCookieName(i));
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const slot = getSlot(request);
|
||||||
|
cookieStore.delete(sessionCookieName(slot));
|
||||||
|
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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 { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
|
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { code, state } = await request.json();
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and decrypt the pending SSO cookie
|
||||||
|
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
||||||
|
if (!pendingCookie) {
|
||||||
|
logger.warn('SSO complete: no pending cookie found');
|
||||||
|
return NextResponse.json({ error: 'No pending SSO session. Please start the login flow again.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = decryptPayload(pendingCookie);
|
||||||
|
if (!pending) {
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
return NextResponse.json({ error: 'Invalid SSO session' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate state
|
||||||
|
if (pending.state !== state) {
|
||||||
|
logger.warn('SSO complete: state mismatch');
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
return NextResponse.json({ error: 'State mismatch' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate TTL
|
||||||
|
const createdAt = pending.created_at as number;
|
||||||
|
if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) {
|
||||||
|
logger.warn('SSO complete: pending session expired');
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
return NextResponse.json({ error: 'SSO session expired. Please try again.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeVerifier = pending.code_verifier as string;
|
||||||
|
const redirectUri = pending.redirect_uri as string;
|
||||||
|
|
||||||
|
if (!codeVerifier || !redirectUri) {
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
return NextResponse.json({ error: 'Invalid SSO session data' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
|
||||||
|
|
||||||
|
// Store refresh token
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
const cookieName = refreshTokenCookieName(0);
|
||||||
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete pending cookie
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
expires_in: tokens.expires_in,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up pending cookie on any error
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
logger.error('SSO complete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
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 { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
|
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
|
||||||
|
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { redirect_uri, locale } = await request.json();
|
||||||
|
|
||||||
|
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
||||||
|
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
|
||||||
|
try {
|
||||||
|
const redirectOrigin = new URL(redirect_uri).origin;
|
||||||
|
if (redirectOrigin !== requestOrigin) {
|
||||||
|
logger.warn('SSO start: redirect_uri origin mismatch', { redirectOrigin, requestOrigin });
|
||||||
|
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId, discoveryUrl } = getRequiredConfig();
|
||||||
|
const metadata = await discoverOAuth(discoveryUrl);
|
||||||
|
|
||||||
|
if (!metadata?.authorization_endpoint) {
|
||||||
|
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate PKCE + state server-side
|
||||||
|
const codeVerifier = generateCodeVerifierServer();
|
||||||
|
const codeChallenge = generateCodeChallengeServer(codeVerifier);
|
||||||
|
const state = generateStateServer();
|
||||||
|
|
||||||
|
// Encrypt and store in httpOnly cookie
|
||||||
|
const pendingData = {
|
||||||
|
state,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
redirect_uri,
|
||||||
|
created_at: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const encrypted = encryptPayload(pendingData);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const baseCookieOpts = getCookieOptions();
|
||||||
|
cookieStore.set(SSO_PENDING_COOKIE, encrypted, {
|
||||||
|
...baseCookieOpts,
|
||||||
|
maxAge: SSO_PENDING_MAX_AGE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build authorize URL
|
||||||
|
const authUrl = new URL(metadata.authorization_endpoint);
|
||||||
|
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('state', state);
|
||||||
|
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||||
|
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
|
||||||
|
if (locale) {
|
||||||
|
authUrl.searchParams.set('ui_locales', locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
authorize_url: authUrl.toString(),
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('SSO start error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||||
|
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
|
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||||
|
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||||
|
return bodySlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
|
if (raw === null) return 0;
|
||||||
|
|
||||||
|
const slot = parseInt(raw, 10);
|
||||||
|
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { serverUrl, username, authHeader, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
|
if (!serverUrl || !username || !authHeader) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request, bodySlot);
|
||||||
|
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
||||||
|
|
||||||
|
await setStalwartAuthContext(slot, {
|
||||||
|
serverUrl: normalizedServerUrl,
|
||||||
|
username,
|
||||||
|
authHeader,
|
||||||
|
});
|
||||||
|
|
||||||
|
void recordLogin(username, normalizedServerUrl);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Failed to store Stalwart auth context', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
-83
@@ -1,100 +1,39 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
import { REFRESH_TOKEN_COOKIE } from '@/lib/oauth/tokens';
|
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
function getSlot(request: NextRequest): number {
|
||||||
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
const COOKIE_OPTIONS = {
|
if (raw === null) return 0;
|
||||||
httpOnly: true,
|
const slot = parseInt(raw, 10);
|
||||||
secure: process.env.NODE_ENV === 'production',
|
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
||||||
sameSite: 'lax' as const,
|
return slot;
|
||||||
path: '/',
|
|
||||||
maxAge: 30 * 24 * 60 * 60,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getRequiredConfig() {
|
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
|
||||||
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
|
||||||
const issuerUrl = process.env.OAUTH_ISSUER_URL;
|
|
||||||
if (!clientId || !serverUrl) {
|
|
||||||
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
|
||||||
}
|
|
||||||
const discoveryUrl = issuerUrl?.trim() || serverUrl;
|
|
||||||
if (issuerUrl !== undefined && !issuerUrl.trim()) {
|
|
||||||
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
|
||||||
}
|
|
||||||
return { clientId, serverUrl, discoveryUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getTokenEndpoint(): Promise<string> {
|
|
||||||
const { discoveryUrl } = getRequiredConfig();
|
|
||||||
const metadata = await discoverOAuth(discoveryUrl);
|
|
||||||
if (!metadata?.token_endpoint) {
|
|
||||||
throw new Error('OAuth token endpoint not found');
|
|
||||||
}
|
|
||||||
return metadata.token_endpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMetadata(): Promise<import('@/lib/oauth/discovery').OAuthMetadata | null> {
|
|
||||||
const { discoveryUrl } = getRequiredConfig();
|
|
||||||
return discoverOAuth(discoveryUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
|
||||||
const { clientId } = getRequiredConfig();
|
|
||||||
const params = new URLSearchParams({ ...base, client_id: clientId });
|
|
||||||
if (CLIENT_SECRET) {
|
|
||||||
params.set('client_secret', CLIENT_SECRET);
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { code, code_verifier, redirect_uri } = await request.json();
|
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
if (!code || !code_verifier || !redirect_uri) {
|
if (!code || !code_verifier || !redirect_uri) {
|
||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenEndpoint = await getTokenEndpoint();
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
|
|
||||||
const params = buildOAuthParams({
|
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri);
|
||||||
grant_type: 'authorization_code',
|
|
||||||
code,
|
|
||||||
redirect_uri,
|
|
||||||
code_verifier,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokenResponse = await fetch(tokenEndpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
|
||||||
const errorText = await tokenResponse.text();
|
|
||||||
logger.error('Token exchange failed', { status: tokenResponse.status, error: errorText });
|
|
||||||
return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await tokenResponse.json();
|
|
||||||
|
|
||||||
if (!tokens.access_token) {
|
|
||||||
logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) });
|
|
||||||
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
expires_in: tokens.expires_in || 3600,
|
expires_in: tokens.expires_in,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -104,10 +43,12 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT() {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
||||||
@@ -129,7 +70,7 @@ export async function PUT() {
|
|||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
const errorText = await tokenResponse.text();
|
const errorText = await tokenResponse.text();
|
||||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||||
cookieStore.delete(REFRESH_TOKEN_COOKIE);
|
cookieStore.delete(cookieName);
|
||||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +82,7 @@ export async function PUT() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -154,10 +95,39 @@ export async function PUT() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||||
|
|
||||||
|
if (all) {
|
||||||
|
// Revoke and delete all refresh token cookies (slots 0-4)
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const name = refreshTokenCookieName(i);
|
||||||
|
const token = cookieStore.get(name)?.value;
|
||||||
|
if (token) {
|
||||||
|
// Best-effort revocation
|
||||||
|
try {
|
||||||
|
const metadata = await getMetadata().catch(() => null);
|
||||||
|
if (metadata?.revocation_endpoint) {
|
||||||
|
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' });
|
||||||
|
await fetch(metadata.revocation_endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString(),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
cookieStore.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
const metadata = await getMetadata().catch((err) => {
|
const metadata = await getMetadata().catch((err) => {
|
||||||
logger.warn('Failed to discover OAuth metadata during logout', {
|
logger.warn('Failed to discover OAuth metadata during logout', {
|
||||||
error: err instanceof Error ? err.message : 'Unknown error',
|
error: err instanceof Error ? err.message : 'Unknown error',
|
||||||
@@ -186,7 +156,7 @@ export async function DELETE() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cookieStore.delete(REFRESH_TOKEN_COOKIE);
|
cookieStore.delete(cookieName);
|
||||||
}
|
}
|
||||||
|
|
||||||
let end_session_url: string | undefined;
|
let end_session_url: string | undefined;
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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 { 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
|
*
|
||||||
|
* This allows 2FA users who log in with basic auth + TOTP to upgrade
|
||||||
|
* to token-based auth, avoiding session expiry when the TOTP rotates.
|
||||||
|
*
|
||||||
|
* Tries three strategies:
|
||||||
|
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
|
||||||
|
* 2. ROPC grant without client_id
|
||||||
|
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function tryTokenRequest(
|
||||||
|
tokenEndpoint: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
extraHeaders?: Record<string, string>,
|
||||||
|
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
|
||||||
|
const response = await fetch(tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await response.json();
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
return { ok: false, status: 502, error: 'Response missing access_token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, tokens };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||||
|
// 1. Try OAuth discovery
|
||||||
|
const metadata = await discoverOAuth(serverUrl);
|
||||||
|
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||||
|
|
||||||
|
// 2. Try common Stalwart token endpoint paths directly
|
||||||
|
const candidates = [
|
||||||
|
`${serverUrl}/auth/token`,
|
||||||
|
`${serverUrl}/api/oauth/token`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const url of candidates) {
|
||||||
|
try {
|
||||||
|
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
|
||||||
|
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
|
||||||
|
if (probe.status !== 404 && probe.status !== 405) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error - endpoint not reachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { serverUrl, username, password, slot: bodySlot } = 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;
|
||||||
|
|
||||||
|
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||||
|
// caller cannot point this route at internal hosts. Only when no server
|
||||||
|
// URL 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);
|
||||||
|
|
||||||
|
let upstreamUrl: string;
|
||||||
|
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) {
|
||||||
|
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(tokenEndpoint, upstreamUrl, username, password, slot);
|
||||||
|
} 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attemptAllStrategies(
|
||||||
|
tokenEndpoint: string,
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
slot: number,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||||
|
|
||||||
|
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||||
|
const 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 }> = [];
|
||||||
|
|
||||||
|
// Strategy 1: ROPC with client_id (if configured)
|
||||||
|
if (clientId) {
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
|
||||||
|
if (clientSecret) params.set('client_secret', clientSecret);
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: ROPC without client_id
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password', username, password });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password' });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 4: client_credentials with Basic Auth (last resort)
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('TOTP token exchange: all strategies failed', { attempts });
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'token_exchange_failed',
|
||||||
|
detail: 'All token exchange strategies failed',
|
||||||
|
attempts,
|
||||||
|
}, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeAndRespond(
|
||||||
|
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||||
|
slot: number,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
expires_in: tokens.expires_in || 3600,
|
||||||
|
has_refresh_token: !!tokens.refresh_token,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
interface DiscoveryAccountRequest {
|
||||||
|
key: string;
|
||||||
|
candidates: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscoveryResult {
|
||||||
|
url: string | null;
|
||||||
|
resolvedAccount: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicUrl(serverUrl: string, path: string): string {
|
||||||
|
return new URL(path, serverUrl).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeCalendarHome(serverUrl: string, authHeader: string, accountName: string): Promise<string | null> {
|
||||||
|
const targetUrl = buildPublicUrl(serverUrl, `/dav/cal/${encodeURIComponent(accountName)}`);
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: 'PROPFIND',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
Depth: '0',
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
},
|
||||||
|
body: `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:">
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:displayname/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>`,
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 207) {
|
||||||
|
return targetUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get('Location');
|
||||||
|
if (location) {
|
||||||
|
return new URL(location, targetUrl).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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().catch(() => ({}));
|
||||||
|
const accounts = Array.isArray(body.accounts) ? body.accounts as DiscoveryAccountRequest[] : [];
|
||||||
|
const wellKnownUrl = buildPublicUrl(creds.serverUrl, '/.well-known/caldav');
|
||||||
|
const discovered: Record<string, DiscoveryResult> = {};
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
if (!account?.key) continue;
|
||||||
|
|
||||||
|
const candidates = Array.from(new Set(
|
||||||
|
(account.candidates || [])
|
||||||
|
.map((candidate) => candidate?.trim())
|
||||||
|
.filter((candidate): candidate is string => Boolean(candidate))
|
||||||
|
));
|
||||||
|
|
||||||
|
let url: string | null = null;
|
||||||
|
let resolvedAccount: string | null = null;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
url = await probeCalendarHome(creds.serverUrl, creds.authHeader, candidate);
|
||||||
|
if (url) {
|
||||||
|
resolvedAccount = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('CalDAV discovery probe failed', {
|
||||||
|
accountKey: account.key,
|
||||||
|
candidate,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discovered[account.key] = { url, resolvedAccount };
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
wellKnownUrl,
|
||||||
|
accounts: discovered,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('CalDAV discovery failed', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-21
@@ -1,36 +1,56 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration endpoint
|
* Runtime configuration endpoint
|
||||||
*
|
*
|
||||||
* This endpoint serves configuration values that can be set at runtime
|
* This endpoint serves configuration values that can be set at runtime
|
||||||
* via environment variables, enabling post-build configuration for
|
* via environment variables or admin dashboard overrides, enabling
|
||||||
* Docker deployments.
|
* post-build configuration for Docker deployments.
|
||||||
*
|
*
|
||||||
* Priority order:
|
* Priority order:
|
||||||
* 1. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
* 1. Admin dashboard overrides (data/admin/config.json)
|
||||||
* 2. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
* 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||||
* 3. Default values
|
* 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
||||||
|
* 4. Default values
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
logger.debug('Config requested');
|
logger.debug('Config requested');
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
|
||||||
|
const appName = configManager.get<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||||
|
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
||||||
|
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||||
|
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
|
||||||
|
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
|
||||||
|
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail',
|
appName,
|
||||||
jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '',
|
jmapServerUrl,
|
||||||
oauthEnabled: process.env.OAUTH_ENABLED === 'true',
|
oauthEnabled,
|
||||||
oauthOnly: process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true',
|
oauthOnly,
|
||||||
oauthClientId: process.env.OAUTH_CLIENT_ID || '',
|
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||||
oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '',
|
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||||
rememberMeEnabled: !!process.env.SESSION_SECRET,
|
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
|
||||||
settingsSyncEnabled: process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET,
|
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
|
||||||
stalwartFeaturesEnabled: process.env.STALWART_FEATURES !== 'false',
|
stalwartFeaturesEnabled,
|
||||||
devMode: process.env.DEV_MOCK_JMAP === 'true',
|
devMode: configManager.get<boolean>('devMode', false),
|
||||||
loginLogoLightUrl: process.env.LOGIN_LOGO_LIGHT_URL || '/branding/Bulwark_Logo_Color.svg',
|
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||||
loginLogoDarkUrl: process.env.LOGIN_LOGO_DARK_URL || '/branding/Bulwark_Logo_White.svg',
|
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
|
||||||
loginCompanyName: process.env.LOGIN_COMPANY_NAME || '',
|
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
|
||||||
loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '',
|
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||||
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
|
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||||
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
|
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
|
||||||
|
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
|
||||||
|
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||||
|
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||||
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
|
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||||
|
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||||
|
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||||
|
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+352
-26
@@ -40,32 +40,358 @@ function isValidDomain(domain: string): boolean {
|
|||||||
|
|
||||||
// Known multi-part TLDs where the registrable domain includes one extra label.
|
// Known multi-part TLDs where the registrable domain includes one extra label.
|
||||||
const MULTI_PART_TLDS = new Set([
|
const MULTI_PART_TLDS = new Set([
|
||||||
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk",
|
// .ac
|
||||||
"co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp",
|
"com.ac", "gov.ac", "mil.ac", "net.ac", "org.ac",
|
||||||
"co.kr", "or.kr", "go.kr", "ac.kr",
|
// .ae
|
||||||
"co.in", "net.in", "org.in", "ac.in", "gov.in",
|
"ac.ae", "co.ae", "gov.ae", "mil.ae", "name.ae", "net.ae", "org.ae", "pro.ae", "sch.ae",
|
||||||
"co.nz", "org.nz", "net.nz", "govt.nz", "ac.nz",
|
// .af
|
||||||
"co.za", "org.za", "net.za", "gov.za", "ac.za",
|
"com.af", "edu.af", "gov.af", "net.af", "org.af",
|
||||||
"com.au", "net.au", "org.au", "edu.au", "gov.au",
|
// .al
|
||||||
"com.br", "net.br", "org.br", "edu.br", "gov.br",
|
"com.al", "edu.al", "gov.al", "mil.al", "net.al", "org.al",
|
||||||
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn",
|
// .ao
|
||||||
"com.mx", "net.mx", "org.mx", "gob.mx", "edu.mx",
|
"co.ao", "ed.ao", "gv.ao", "it.ao", "og.ao", "pb.ao",
|
||||||
"com.ar", "net.ar", "org.ar", "gob.ar", "edu.ar",
|
// .ar
|
||||||
"com.tw", "net.tw", "org.tw", "edu.tw", "gov.tw",
|
"com.ar", "edu.ar", "gob.ar", "gov.ar", "int.ar", "mil.ar", "net.ar", "org.ar", "tur.ar",
|
||||||
"com.hk", "net.hk", "org.hk", "edu.hk", "gov.hk",
|
// .at
|
||||||
"com.sg", "net.sg", "org.sg", "edu.sg", "gov.sg",
|
"ac.at", "co.at", "gv.at", "or.at",
|
||||||
"com.my", "net.my", "org.my", "edu.my", "gov.my",
|
// .au
|
||||||
"com.ph", "net.ph", "org.ph", "edu.ph", "gov.ph",
|
"asn.au", "com.au", "csiro.au", "edu.au", "gov.au", "id.au", "net.au", "org.au",
|
||||||
"com.pk", "net.pk", "org.pk", "edu.pk", "gov.pk",
|
// .ba
|
||||||
"com.ng", "net.ng", "org.ng", "edu.ng", "gov.ng",
|
"co.ba", "com.ba", "edu.ba", "gov.ba", "mil.ba", "net.ba", "org.ba", "rs.ba",
|
||||||
"co.il", "org.il", "net.il", "ac.il", "gov.il",
|
"unbi.ba", "unmo.ba", "unsa.ba", "untz.ba", "unze.ba",
|
||||||
"co.th", "or.th", "ac.th", "go.th", "in.th",
|
// .bb
|
||||||
"co.id", "or.id", "ac.id", "go.id", "web.id",
|
"biz.bb", "co.bb", "com.bb", "edu.bb", "gov.bb", "info.bb", "net.bb", "org.bb",
|
||||||
"com.tr", "net.tr", "org.tr", "edu.tr", "gov.tr",
|
"store.bb", "tv.bb",
|
||||||
"com.ua", "net.ua", "org.ua", "edu.ua", "gov.ua",
|
// .bh
|
||||||
"com.eg", "net.eg", "org.eg", "edu.eg", "gov.eg",
|
"biz.bh", "cc.bh", "com.bh", "edu.bh", "gov.bh", "info.bh", "net.bh", "org.bh",
|
||||||
"com.sa", "net.sa", "org.sa", "edu.sa", "gov.sa",
|
// .bn
|
||||||
"co.ke", "or.ke", "ac.ke", "go.ke", "ne.ke",
|
"com.bn", "edu.bn", "gov.bn", "net.bn", "org.bn",
|
||||||
|
// .bo
|
||||||
|
"com.bo", "edu.bo", "gob.bo", "gov.bo", "int.bo", "mil.bo", "net.bo", "org.bo", "tv.bo",
|
||||||
|
// .br
|
||||||
|
"adm.br", "adv.br", "agr.br", "am.br", "arq.br", "art.br", "ato.br", "b.br",
|
||||||
|
"bio.br", "blog.br", "bmd.br", "cim.br", "cng.br", "cnt.br", "com.br", "coop.br",
|
||||||
|
"ecn.br", "edu.br", "eng.br", "esp.br", "etc.br", "eti.br", "far.br", "flog.br",
|
||||||
|
"fm.br", "fnd.br", "fot.br", "fst.br", "g12.br", "ggf.br", "gov.br", "imb.br",
|
||||||
|
"ind.br", "inf.br", "jor.br", "jus.br", "lel.br", "mat.br", "med.br", "mil.br",
|
||||||
|
"mus.br", "net.br", "nom.br", "not.br", "ntr.br", "odo.br", "org.br", "ppg.br",
|
||||||
|
"pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br",
|
||||||
|
"trd.br", "tur.br", "tv.br", "vet.br", "vlog.br", "wiki.br", "zlg.br",
|
||||||
|
// .bs
|
||||||
|
"com.bs", "edu.bs", "gov.bs", "net.bs", "org.bs",
|
||||||
|
// .bz
|
||||||
|
"com.bz", "edu.bz", "gov.bz", "net.bz", "org.bz",
|
||||||
|
// .ca
|
||||||
|
"ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca",
|
||||||
|
"nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca",
|
||||||
|
// .ck
|
||||||
|
"biz.ck", "co.ck", "edu.ck", "gen.ck", "gov.ck", "info.ck", "net.ck", "org.ck",
|
||||||
|
// .cn
|
||||||
|
"ac.cn", "ah.cn", "bj.cn", "com.cn", "cq.cn", "edu.cn", "fj.cn", "gd.cn",
|
||||||
|
"gov.cn", "gs.cn", "gx.cn", "gz.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn",
|
||||||
|
"hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mil.cn", "net.cn",
|
||||||
|
"nm.cn", "nx.cn", "org.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn",
|
||||||
|
"sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn",
|
||||||
|
// .co
|
||||||
|
"com.co", "edu.co", "gov.co", "mil.co", "net.co", "nom.co", "org.co",
|
||||||
|
// .cr
|
||||||
|
"ac.cr", "co.cr", "ed.cr", "fi.cr", "go.cr", "or.cr", "sa.cr",
|
||||||
|
// .cy
|
||||||
|
"ac.cy", "biz.cy", "com.cy", "ekloges.cy", "gov.cy", "ltd.cy", "name.cy",
|
||||||
|
"net.cy", "org.cy", "parliament.cy", "press.cy", "pro.cy", "tm.cy",
|
||||||
|
// .do
|
||||||
|
"art.do", "com.do", "edu.do", "gob.do", "gov.do", "mil.do", "net.do", "org.do",
|
||||||
|
"sld.do", "web.do",
|
||||||
|
// .dz
|
||||||
|
"art.dz", "asso.dz", "com.dz", "edu.dz", "gov.dz", "net.dz", "org.dz", "pol.dz",
|
||||||
|
// .ec
|
||||||
|
"com.ec", "edu.ec", "fin.ec", "gov.ec", "info.ec", "med.ec", "mil.ec", "net.ec",
|
||||||
|
"org.ec", "pro.ec",
|
||||||
|
// .eg
|
||||||
|
"com.eg", "edu.eg", "eun.eg", "gov.eg", "mil.eg", "name.eg", "net.eg", "org.eg", "sci.eg",
|
||||||
|
// .er
|
||||||
|
"com.er", "edu.er", "gov.er", "ind.er", "mil.er", "net.er", "org.er", "rochest.er", "w.er",
|
||||||
|
// .es
|
||||||
|
"com.es", "edu.es", "gob.es", "nom.es", "org.es",
|
||||||
|
// .et
|
||||||
|
"biz.et", "com.et", "edu.et", "gov.et", "info.et", "name.et", "net.et", "org.et",
|
||||||
|
// .fj
|
||||||
|
"ac.fj", "biz.fj", "com.fj", "info.fj", "mil.fj", "name.fj", "net.fj", "org.fj", "pro.fj",
|
||||||
|
// .fk
|
||||||
|
"ac.fk", "co.fk", "gov.fk", "net.fk", "nom.fk", "org.fk",
|
||||||
|
// .fr
|
||||||
|
"asso.fr", "com.fr", "gouv.fr", "nom.fr", "prd.fr", "presse.fr", "tm.fr",
|
||||||
|
// .gg
|
||||||
|
"co.gg", "net.gg", "org.gg",
|
||||||
|
// .gh
|
||||||
|
"com.gh", "edu.gh", "gov.gh", "mil.gh", "org.gh",
|
||||||
|
// .gn
|
||||||
|
"ac.gn", "com.gn", "gov.gn", "net.gn", "org.gn",
|
||||||
|
// .gr
|
||||||
|
"com.gr", "edu.gr", "gov.gr", "mil.gr", "net.gr", "org.gr",
|
||||||
|
// .gt
|
||||||
|
"com.gt", "edu.gt", "gob.gt", "ind.gt", "mil.gt", "net.gt", "org.gt",
|
||||||
|
// .gu
|
||||||
|
"com.gu", "edu.gu", "gov.gu", "net.gu", "org.gu",
|
||||||
|
// .hk
|
||||||
|
"com.hk", "edu.hk", "gov.hk", "idv.hk", "net.hk", "org.hk",
|
||||||
|
// .id
|
||||||
|
"ac.id", "co.id", "go.id", "mil.id", "net.id", "or.id", "sch.id", "web.id",
|
||||||
|
// .il
|
||||||
|
"ac.il", "co.il", "gov.il", "idf.il", "k12.il", "muni.il", "net.il", "org.il",
|
||||||
|
// .in
|
||||||
|
"4fd.in", "ac.in", "co.in", "edu.in", "ernet.in", "firm.in", "gen.in", "gov.in",
|
||||||
|
"ind.in", "mil.in", "net.in", "nic.in", "org.in", "res.in",
|
||||||
|
// .iq
|
||||||
|
"com.iq", "edu.iq", "gov.iq", "mil.iq", "net.iq", "org.iq",
|
||||||
|
// .ir
|
||||||
|
"ac.ir", "co.ir", "dnssec.ir", "gov.ir", "id.ir", "net.ir", "org.ir", "sch.ir",
|
||||||
|
// .it
|
||||||
|
"edu.it", "gov.it",
|
||||||
|
// .je
|
||||||
|
"co.je", "net.je", "org.je",
|
||||||
|
// .jo
|
||||||
|
"com.jo", "edu.jo", "gov.jo", "mil.jo", "name.jo", "net.jo", "org.jo", "sch.jo",
|
||||||
|
// .jp
|
||||||
|
"ac.jp", "ad.jp", "co.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", "ne.jp", "or.jp",
|
||||||
|
// .ke
|
||||||
|
"ac.ke", "co.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "ne.ke", "or.ke", "sc.ke",
|
||||||
|
// .kh
|
||||||
|
"com.kh", "edu.kh", "gov.kh", "mil.kh", "net.kh", "org.kh", "per.kh",
|
||||||
|
// .ki
|
||||||
|
"biz.ki", "com.ki", "de.ki", "edu.ki", "gov.ki", "info.ki", "mob.ki", "net.ki",
|
||||||
|
"org.ki", "tel.ki",
|
||||||
|
// .km
|
||||||
|
"asso.km", "com.km", "coop.km", "edu.km", "gouv.km", "medecin.km", "mil.km",
|
||||||
|
"nom.km", "notaires.km", "pharmaciens.km", "presse.km", "tm.km", "veterinaire.km",
|
||||||
|
// .kn
|
||||||
|
"edu.kn", "gov.kn", "net.kn", "org.kn",
|
||||||
|
// .kr
|
||||||
|
"ac.kr", "busan.kr", "chungbuk.kr", "chungnam.kr", "co.kr", "daegu.kr",
|
||||||
|
"daejeon.kr", "es.kr", "gangwon.kr", "go.kr", "gwangju.kr", "gyeongbuk.kr",
|
||||||
|
"gyeonggi.kr", "gyeongnam.kr", "hs.kr", "incheon.kr", "jeju.kr", "jeonbuk.kr",
|
||||||
|
"jeonnam.kr", "kg.kr", "mil.kr", "ms.kr", "ne.kr", "or.kr", "pe.kr", "re.kr",
|
||||||
|
"sc.kr", "seoul.kr", "ulsan.kr",
|
||||||
|
// .kw
|
||||||
|
"com.kw", "edu.kw", "gov.kw", "net.kw", "org.kw",
|
||||||
|
// .ky
|
||||||
|
"com.ky", "edu.ky", "gov.ky", "net.ky", "org.ky",
|
||||||
|
// .kz
|
||||||
|
"com.kz", "edu.kz", "gov.kz", "mil.kz", "net.kz", "org.kz",
|
||||||
|
// .lb
|
||||||
|
"com.lb", "edu.lb", "gov.lb", "net.lb", "org.lb",
|
||||||
|
// .lk
|
||||||
|
"assn.lk", "com.lk", "edu.lk", "gov.lk", "grp.lk", "hotel.lk", "int.lk", "ltd.lk",
|
||||||
|
"net.lk", "ngo.lk", "org.lk", "sch.lk", "soc.lk", "web.lk",
|
||||||
|
// .lr
|
||||||
|
"com.lr", "edu.lr", "gov.lr", "net.lr", "org.lr",
|
||||||
|
// .lv
|
||||||
|
"asn.lv", "com.lv", "conf.lv", "edu.lv", "gov.lv", "id.lv", "mil.lv", "net.lv", "org.lv",
|
||||||
|
// .ly
|
||||||
|
"com.ly", "edu.ly", "gov.ly", "id.ly", "med.ly", "net.ly", "org.ly", "plc.ly", "sch.ly",
|
||||||
|
// .ma
|
||||||
|
"ac.ma", "co.ma", "gov.ma", "net.ma", "org.ma", "press.ma",
|
||||||
|
// .mc
|
||||||
|
"asso.mc", "tm.mc",
|
||||||
|
// .me
|
||||||
|
"ac.me", "co.me", "edu.me", "gov.me", "its.me", "net.me", "org.me", "priv.me",
|
||||||
|
// .mg
|
||||||
|
"com.mg", "edu.mg", "gov.mg", "mil.mg", "nom.mg", "org.mg", "prd.mg", "tm.mg",
|
||||||
|
// .mk
|
||||||
|
"com.mk", "edu.mk", "gov.mk", "inf.mk", "name.mk", "net.mk", "org.mk", "pro.mk",
|
||||||
|
// .ml
|
||||||
|
"com.ml", "edu.ml", "gov.ml", "net.ml", "org.ml", "presse.ml",
|
||||||
|
// .mn
|
||||||
|
"edu.mn", "gov.mn", "org.mn",
|
||||||
|
// .mo
|
||||||
|
"com.mo", "edu.mo", "gov.mo", "net.mo", "org.mo",
|
||||||
|
// .mt
|
||||||
|
"com.mt", "edu.mt", "gov.mt", "net.mt", "org.mt",
|
||||||
|
// .mu
|
||||||
|
"ac.mu", "co.mu", "com.mu", "gov.mu", "net.mu", "or.mu", "org.mu",
|
||||||
|
// .mv
|
||||||
|
"aero.mv", "biz.mv", "com.mv", "coop.mv", "edu.mv", "gov.mv", "info.mv",
|
||||||
|
"int.mv", "mil.mv", "museum.mv", "name.mv", "net.mv", "org.mv", "pro.mv",
|
||||||
|
// .mw
|
||||||
|
"ac.mw", "co.mw", "com.mw", "coop.mw", "edu.mw", "gov.mw", "int.mw",
|
||||||
|
"museum.mw", "net.mw", "org.mw",
|
||||||
|
// .mx
|
||||||
|
"com.mx", "edu.mx", "gob.mx", "net.mx", "org.mx",
|
||||||
|
// .my
|
||||||
|
"com.my", "edu.my", "gov.my", "mil.my", "name.my", "net.my", "org.my", "sch.my",
|
||||||
|
// .mz
|
||||||
|
"ac.mz", "co.mz", "edu.mz", "gov.mz", "org.mz",
|
||||||
|
// .na
|
||||||
|
"co.na", "com.na",
|
||||||
|
// .nf
|
||||||
|
"arts.nf", "com.nf", "firm.nf", "info.nf", "net.nf", "other.nf", "per.nf",
|
||||||
|
"rec.nf", "store.nf", "web.nf",
|
||||||
|
// .ng
|
||||||
|
"biz.ng", "com.ng", "edu.ng", "gov.ng", "mil.ng", "mobi.ng", "name.ng",
|
||||||
|
"net.ng", "org.ng", "sch.ng",
|
||||||
|
// .ni
|
||||||
|
"ac.ni", "co.ni", "com.ni", "edu.ni", "gob.ni", "mil.ni", "net.ni", "nom.ni", "org.ni",
|
||||||
|
// .np
|
||||||
|
"com.np", "edu.np", "gov.np", "mil.np", "net.np", "org.np",
|
||||||
|
// .nr
|
||||||
|
"biz.nr", "com.nr", "edu.nr", "gov.nr", "info.nr", "net.nr", "org.nr",
|
||||||
|
// .nz
|
||||||
|
"ac.nz", "co.nz", "cri.nz", "geek.nz", "gen.nz", "govt.nz", "health.nz",
|
||||||
|
"iwi.nz", "maori.nz", "mil.nz", "net.nz", "org.nz", "parliament.nz", "school.nz",
|
||||||
|
// .om
|
||||||
|
"ac.om", "biz.om", "co.om", "com.om", "edu.om", "gov.om", "med.om", "mil.om",
|
||||||
|
"museum.om", "net.om", "org.om", "pro.om", "sch.om",
|
||||||
|
// .pa
|
||||||
|
"abo.pa", "ac.pa", "com.pa", "edu.pa", "gob.pa", "ing.pa", "med.pa", "net.pa",
|
||||||
|
"nom.pa", "org.pa", "sld.pa",
|
||||||
|
// .pe
|
||||||
|
"com.pe", "edu.pe", "gob.pe", "mil.pe", "net.pe", "nom.pe", "org.pe", "sld.pe",
|
||||||
|
// .ph
|
||||||
|
"com.ph", "edu.ph", "gov.ph", "i.ph", "mil.ph", "net.ph", "ngo.ph", "org.ph",
|
||||||
|
// .pk
|
||||||
|
"biz.pk", "com.pk", "edu.pk", "fam.pk", "gob.pk", "gok.pk", "gon.pk", "gop.pk",
|
||||||
|
"gos.pk", "gov.pk", "net.pk", "org.pk", "web.pk",
|
||||||
|
// .pl
|
||||||
|
"art.pl", "bialystok.pl", "biz.pl", "com.pl", "edu.pl", "gda.pl", "gdansk.pl",
|
||||||
|
"gorzow.pl", "gov.pl", "info.pl", "katowice.pl", "krakow.pl", "lodz.pl",
|
||||||
|
"lublin.pl", "mil.pl", "net.pl", "ngo.pl", "olsztyn.pl", "org.pl", "poznan.pl",
|
||||||
|
"pwr.pl", "radom.pl", "slupsk.pl", "szczecin.pl", "torun.pl", "warszawa.pl",
|
||||||
|
"waw.pl", "wroc.pl", "wroclaw.pl", "zgora.pl",
|
||||||
|
// .pr
|
||||||
|
"ac.pr", "biz.pr", "com.pr", "edu.pr", "est.pr", "gov.pr", "info.pr", "isla.pr",
|
||||||
|
"name.pr", "net.pr", "org.pr", "pro.pr", "prof.pr",
|
||||||
|
// .ps
|
||||||
|
"com.ps", "edu.ps", "gov.ps", "net.ps", "org.ps", "plo.ps", "sec.ps",
|
||||||
|
// .pt
|
||||||
|
"com.pt", "edu.pt", "gov.pt", "int.pt", "net.pt", "nome.pt", "org.pt", "publ.pt",
|
||||||
|
// .pw
|
||||||
|
"belau.pw", "co.pw", "ed.pw", "go.pw", "ne.pw", "or.pw",
|
||||||
|
// .py
|
||||||
|
"com.py", "edu.py", "gov.py", "mil.py", "net.py", "org.py",
|
||||||
|
// .qa
|
||||||
|
"com.qa", "edu.qa", "gov.qa", "mil.qa", "net.qa", "org.qa",
|
||||||
|
// .re
|
||||||
|
"asso.re", "com.re", "nom.re",
|
||||||
|
// .ro
|
||||||
|
"arts.ro", "com.ro", "firm.ro", "info.ro", "nom.ro", "nt.ro", "org.ro",
|
||||||
|
"rec.ro", "store.ro", "tm.ro", "www.ro",
|
||||||
|
// .rs
|
||||||
|
"ac.rs", "co.rs", "edu.rs", "gov.rs", "in.rs", "org.rs",
|
||||||
|
// .ru
|
||||||
|
"ac.ru", "adygeya.ru", "altai.ru", "amur.ru", "arkhangelsk.ru", "astrakhan.ru",
|
||||||
|
"bashkiria.ru", "belgorod.ru", "bir.ru", "bryansk.ru", "buryatia.ru", "cbg.ru",
|
||||||
|
"chel.ru", "chelyabinsk.ru", "chita.ru", "chukotka.ru", "chuvashia.ru", "com.ru",
|
||||||
|
"dagestan.ru", "e-burg.ru", "edu.ru", "gov.ru", "grozny.ru", "int.ru",
|
||||||
|
"irkutsk.ru", "ivanovo.ru", "izhevsk.ru", "jar.ru", "joshkar-ola.ru",
|
||||||
|
"kalmykia.ru", "kaluga.ru", "kamchatka.ru", "karelia.ru", "kazan.ru", "kchr.ru",
|
||||||
|
"kemerovo.ru", "khabarovsk.ru", "khakassia.ru", "khv.ru", "kirov.ru",
|
||||||
|
"koenig.ru", "komi.ru", "kostroma.ru", "kranoyarsk.ru", "kuban.ru", "kurgan.ru",
|
||||||
|
"kursk.ru", "lipetsk.ru", "magadan.ru", "mari.ru", "mari-el.ru", "marine.ru",
|
||||||
|
"mil.ru", "mordovia.ru", "mosreg.ru", "msk.ru", "murmansk.ru", "nalchik.ru",
|
||||||
|
"net.ru", "nnov.ru", "nov.ru", "novosibirsk.ru", "nsk.ru", "omsk.ru",
|
||||||
|
"orenburg.ru", "org.ru", "oryol.ru", "penza.ru", "perm.ru", "pp.ru", "pskov.ru",
|
||||||
|
"ptz.ru", "rnd.ru", "ryazan.ru", "sakhalin.ru", "samara.ru", "saratov.ru",
|
||||||
|
"simbirsk.ru", "smolensk.ru", "spb.ru", "stavropol.ru", "stv.ru", "surgut.ru",
|
||||||
|
"tambov.ru", "tatarstan.ru", "tom.ru", "tomsk.ru", "tsaritsyn.ru", "tsk.ru",
|
||||||
|
"tula.ru", "tuva.ru", "tver.ru", "tyumen.ru", "udm.ru", "udmurtia.ru",
|
||||||
|
"ulan-ude.ru", "vladikavkaz.ru", "vladimir.ru", "vladivostok.ru", "volgograd.ru",
|
||||||
|
"vologda.ru", "voronezh.ru", "vrn.ru", "vyatka.ru", "yakutia.ru", "yamal.ru",
|
||||||
|
"yekaterinburg.ru", "yuzhno-sakhalinsk.ru",
|
||||||
|
// .rw
|
||||||
|
"ac.rw", "co.rw", "com.rw", "edu.rw", "gouv.rw", "gov.rw", "int.rw", "mil.rw", "net.rw",
|
||||||
|
// .sa
|
||||||
|
"com.sa", "edu.sa", "gov.sa", "med.sa", "net.sa", "org.sa", "pub.sa", "sch.sa",
|
||||||
|
// .sb
|
||||||
|
"com.sb", "edu.sb", "gov.sb", "net.sb", "org.sb",
|
||||||
|
// .sc
|
||||||
|
"com.sc", "edu.sc", "gov.sc", "net.sc", "org.sc",
|
||||||
|
// .sd
|
||||||
|
"com.sd", "edu.sd", "gov.sd", "info.sd", "med.sd", "net.sd", "org.sd", "tv.sd",
|
||||||
|
// .se
|
||||||
|
"a.se", "ac.se", "b.se", "bd.se", "c.se", "d.se", "e.se", "f.se", "g.se",
|
||||||
|
"h.se", "i.se", "k.se", "l.se", "m.se", "n.se", "o.se", "org.se", "p.se",
|
||||||
|
"parti.se", "pp.se", "press.se", "r.se", "s.se", "t.se", "tm.se", "u.se",
|
||||||
|
"w.se", "x.se", "y.se", "z.se",
|
||||||
|
// .sg
|
||||||
|
"com.sg", "edu.sg", "gov.sg", "idn.sg", "net.sg", "org.sg", "per.sg",
|
||||||
|
// .sh
|
||||||
|
"co.sh", "com.sh", "edu.sh", "gov.sh", "net.sh", "nom.sh", "org.sh",
|
||||||
|
// .sl
|
||||||
|
"com.sl", "edu.sl", "gov.sl", "net.sl", "org.sl",
|
||||||
|
// .sn
|
||||||
|
"art.sn", "com.sn", "edu.sn", "gouv.sn", "org.sn", "perso.sn", "univ.sn",
|
||||||
|
// .st
|
||||||
|
"co.st", "com.st", "consulado.st", "edu.st", "embaixada.st", "gov.st", "mil.st",
|
||||||
|
"net.st", "org.st", "principe.st", "saotome.st", "store.st",
|
||||||
|
// .sv
|
||||||
|
"com.sv", "edu.sv", "gob.sv", "org.sv", "red.sv",
|
||||||
|
// .sy
|
||||||
|
"com.sy", "edu.sy", "gov.sy", "mil.sy", "net.sy", "news.sy", "org.sy",
|
||||||
|
// .sz
|
||||||
|
"ac.sz", "co.sz", "org.sz",
|
||||||
|
// .th
|
||||||
|
"ac.th", "co.th", "go.th", "in.th", "mi.th", "net.th", "or.th",
|
||||||
|
// .tj
|
||||||
|
"ac.tj", "biz.tj", "co.tj", "com.tj", "edu.tj", "go.tj", "gov.tj", "info.tj",
|
||||||
|
"int.tj", "mil.tj", "name.tj", "net.tj", "nic.tj", "org.tj", "test.tj", "web.tj",
|
||||||
|
// .tn
|
||||||
|
"agrinet.tn", "com.tn", "defense.tn", "edunet.tn", "ens.tn", "fin.tn", "gov.tn",
|
||||||
|
"ind.tn", "info.tn", "intl.tn", "mincom.tn", "nat.tn", "net.tn", "org.tn",
|
||||||
|
"perso.tn", "rnrt.tn", "rns.tn", "rnu.tn", "tourism.tn",
|
||||||
|
// .tr
|
||||||
|
"av.tr", "bbs.tr", "bel.tr", "biz.tr", "com.tr", "dr.tr", "edu.tr", "gen.tr",
|
||||||
|
"gov.tr", "info.tr", "k12.tr", "name.tr", "net.tr", "org.tr", "pol.tr",
|
||||||
|
"tel.tr", "tsk.tr", "tv.tr", "web.tr",
|
||||||
|
// .tt
|
||||||
|
"aero.tt", "biz.tt", "cat.tt", "co.tt", "com.tt", "coop.tt", "edu.tt", "gov.tt",
|
||||||
|
"info.tt", "int.tt", "jobs.tt", "mil.tt", "mobi.tt", "museum.tt", "name.tt",
|
||||||
|
"net.tt", "org.tt", "pro.tt", "tel.tt", "travel.tt",
|
||||||
|
// .tw
|
||||||
|
"club.tw", "com.tw", "ebiz.tw", "edu.tw", "game.tw", "gov.tw", "idv.tw",
|
||||||
|
"mil.tw", "net.tw", "org.tw",
|
||||||
|
// .tz
|
||||||
|
"ac.tz", "co.tz", "go.tz", "ne.tz", "or.tz",
|
||||||
|
// .ua
|
||||||
|
"biz.ua", "cherkassy.ua", "chernigov.ua", "chernovtsy.ua", "ck.ua", "cn.ua",
|
||||||
|
"co.ua", "com.ua", "crimea.ua", "cv.ua", "dn.ua", "dnepropetrovsk.ua",
|
||||||
|
"donetsk.ua", "dp.ua", "edu.ua", "gov.ua", "if.ua", "in.ua",
|
||||||
|
"ivano-frankivsk.ua", "kh.ua", "kharkov.ua", "kherson.ua", "khmelnitskiy.ua",
|
||||||
|
"kiev.ua", "kirovograd.ua", "km.ua", "kr.ua", "ks.ua", "kv.ua", "lg.ua",
|
||||||
|
"lugansk.ua", "lutsk.ua", "lviv.ua", "me.ua", "mk.ua", "net.ua",
|
||||||
|
"nikolaev.ua", "od.ua", "odessa.ua", "org.ua", "pl.ua", "poltava.ua", "pp.ua",
|
||||||
|
"rovno.ua", "rv.ua", "sebastopol.ua", "sumy.ua", "te.ua", "ternopil.ua",
|
||||||
|
"uzhgorod.ua", "vinnica.ua", "vn.ua", "zaporizhzhe.ua", "zhitomir.ua",
|
||||||
|
"zp.ua", "zt.ua",
|
||||||
|
// .ug
|
||||||
|
"ac.ug", "co.ug", "go.ug", "ne.ug", "or.ug", "org.ug", "sc.ug",
|
||||||
|
// .uk
|
||||||
|
"ac.uk", "bl.uk", "british-library.uk", "co.uk", "cym.uk", "gov.uk", "govt.uk",
|
||||||
|
"icnet.uk", "jet.uk", "lea.uk", "ltd.uk", "me.uk", "mil.uk", "mod.uk",
|
||||||
|
"national-library-scotland.uk", "nel.uk", "net.uk", "nhs.uk", "nic.uk",
|
||||||
|
"nls.uk", "org.uk", "orgn.uk", "parliament.uk", "plc.uk", "police.uk",
|
||||||
|
"sch.uk", "scot.uk", "soc.uk",
|
||||||
|
// .us
|
||||||
|
"4fd.us", "dni.us", "fed.us", "isa.us", "kids.us", "nsn.us",
|
||||||
|
// .uy
|
||||||
|
"com.uy", "edu.uy", "gub.uy", "mil.uy", "net.uy", "org.uy",
|
||||||
|
// .ve
|
||||||
|
"co.ve", "com.ve", "edu.ve", "gob.ve", "info.ve", "mil.ve", "net.ve", "org.ve", "web.ve",
|
||||||
|
// .vi
|
||||||
|
"co.vi", "com.vi", "k12.vi", "net.vi", "org.vi",
|
||||||
|
// .vn
|
||||||
|
"ac.vn", "biz.vn", "com.vn", "edu.vn", "gov.vn", "health.vn", "info.vn",
|
||||||
|
"int.vn", "name.vn", "net.vn", "org.vn", "pro.vn",
|
||||||
|
// .ye
|
||||||
|
"co.ye", "com.ye", "gov.ye", "ltd.ye", "me.ye", "net.ye", "org.ye", "plc.ye",
|
||||||
|
// .yu
|
||||||
|
"ac.yu", "co.yu", "edu.yu", "gov.yu", "org.yu",
|
||||||
|
// .za
|
||||||
|
"ac.za", "agric.za", "alt.za", "bourse.za", "city.za", "co.za", "cybernet.za",
|
||||||
|
"db.za", "edu.za", "gov.za", "grondar.za", "iaccess.za", "imt.za", "inca.za",
|
||||||
|
"landesign.za", "law.za", "mil.za", "net.za", "ngo.za", "nis.za", "nom.za",
|
||||||
|
"olivetti.za", "org.za", "pix.za", "school.za", "tm.za", "web.za",
|
||||||
|
// .zm
|
||||||
|
"ac.zm", "co.zm", "com.zm", "edu.zm", "gov.zm", "net.zm", "org.zm", "sch.zm",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function getRootDomain(domain: string): string {
|
function getRootDomain(domain: string): string {
|
||||||
|
|||||||
+34
-50
@@ -1,47 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
|
||||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const FETCH_TIMEOUT_MS = 15000;
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
function isValidExternalUrl(urlString: string): boolean {
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(urlString);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = url.hostname.toLowerCase();
|
|
||||||
|
|
||||||
// Block private/internal hostnames
|
|
||||||
if (
|
|
||||||
hostname === 'localhost' ||
|
|
||||||
hostname === '127.0.0.1' ||
|
|
||||||
hostname === '::1' ||
|
|
||||||
hostname === '0.0.0.0' ||
|
|
||||||
hostname.endsWith('.local') ||
|
|
||||||
hostname.endsWith('.internal') ||
|
|
||||||
hostname.endsWith('.arpa') ||
|
|
||||||
hostname.startsWith('10.') ||
|
|
||||||
hostname.startsWith('192.168.') ||
|
|
||||||
hostname.startsWith('169.254.') ||
|
|
||||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block URLs with credentials
|
|
||||||
if (url.username || url.password) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { url?: string };
|
let body: { url?: string };
|
||||||
try {
|
try {
|
||||||
@@ -56,7 +18,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidExternalUrl(url)) {
|
if (!(await isPublicHttpUrl(url))) {
|
||||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,20 +26,42 @@ export async function POST(request: NextRequest) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const MAX_REDIRECTS = 5;
|
||||||
signal: controller.signal,
|
let currentUrl = url;
|
||||||
headers: {
|
let response: Response | undefined;
|
||||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
|
||||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||||
},
|
if (!(await isPublicHttpUrl(currentUrl))) {
|
||||||
redirect: 'follow',
|
clearTimeout(timeout);
|
||||||
});
|
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await fetch(currentUrl, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||||
|
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||||
|
},
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (!location) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
||||||
|
}
|
||||||
|
currentUrl = new URL(location, currentUrl).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response || !response.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Remote server returned ${response.status}` },
|
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
|
||||||
{ status: 502 }
|
{ status: 502 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-35
@@ -1,10 +1,21 @@
|
|||||||
|
import v8 from 'node:v8';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
// Health check thresholds
|
const MEMORY_WARNING_THRESHOLD = 0.85;
|
||||||
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
|
const MEMORY_CRITICAL_THRESHOLD = 0.95;
|
||||||
const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage
|
|
||||||
|
function getHeapUsagePercent(heapUsed: number, heapTotal: number): number {
|
||||||
|
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
|
||||||
|
const denominator = heapSizeLimit > 0 ? heapSizeLimit : heapTotal;
|
||||||
|
|
||||||
|
if (denominator <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (heapUsed / denominator) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
interface HealthStatus {
|
interface HealthStatus {
|
||||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
@@ -14,6 +25,7 @@ interface HealthStatus {
|
|||||||
memory?: {
|
memory?: {
|
||||||
heapUsed: number;
|
heapUsed: number;
|
||||||
heapTotal: number;
|
heapTotal: number;
|
||||||
|
heapSizeLimit: number;
|
||||||
rss: number;
|
rss: number;
|
||||||
external: number;
|
external: number;
|
||||||
heapUsagePercent: number;
|
heapUsagePercent: number;
|
||||||
@@ -27,14 +39,9 @@ interface HealthStatus {
|
|||||||
/**
|
/**
|
||||||
* Health check endpoint for container orchestration
|
* Health check endpoint for container orchestration
|
||||||
*
|
*
|
||||||
* GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable)
|
* GET /api/health - Liveness probe for container orchestration
|
||||||
* GET /api/health?detailed=true - Detailed diagnostics with memory stats
|
* GET /api/health?detailed=true - Diagnostics with advisory memory warnings
|
||||||
* HEAD /api/health - Lightweight health check (status code only)
|
* HEAD /api/health - Lightweight liveness probe (status code only)
|
||||||
*
|
|
||||||
* Health status based on Node.js heap usage:
|
|
||||||
* - Healthy (200): < 85% heap usage
|
|
||||||
* - Degraded (200): 85-95% heap usage (warnings in detailed mode)
|
|
||||||
* - Unhealthy (503): > 95% heap usage
|
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
@@ -43,38 +50,31 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const memUsage = process.memoryUsage();
|
const memUsage = process.memoryUsage();
|
||||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
|
||||||
|
const heapUsagePercent = getHeapUsagePercent(memUsage.heapUsed, memUsage.heapTotal);
|
||||||
// Determine health status based on memory usage
|
|
||||||
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
let httpStatus = 200;
|
|
||||||
|
|
||||||
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
||||||
status = 'unhealthy';
|
status = 'degraded';
|
||||||
httpStatus = 503;
|
warnings.push(`V8 heap usage is very high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
|
||||||
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
|
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
|
||||||
status = 'degraded';
|
status = 'degraded';
|
||||||
warnings.push(`Memory usage high: ${heapUsagePercent.toFixed(1)}%`);
|
warnings.push(`V8 heap usage is high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response
|
|
||||||
const response: HealthStatus = {
|
const response: HealthStatus = {
|
||||||
status,
|
status: detailed ? status : 'healthy',
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === 'unhealthy') {
|
|
||||||
response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add detailed information if requested
|
|
||||||
if (detailed) {
|
if (detailed) {
|
||||||
response.uptime = process.uptime();
|
response.uptime = process.uptime();
|
||||||
response.version = process.env.npm_package_version || '0.1.0';
|
response.version = process.env.npm_package_version || '0.1.0';
|
||||||
response.memory = {
|
response.memory = {
|
||||||
heapUsed: memUsage.heapUsed,
|
heapUsed: memUsage.heapUsed,
|
||||||
heapTotal: memUsage.heapTotal,
|
heapTotal: memUsage.heapTotal,
|
||||||
|
heapSizeLimit,
|
||||||
rss: memUsage.rss,
|
rss: memUsage.rss,
|
||||||
external: memUsage.external,
|
external: memUsage.external,
|
||||||
heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
|
heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
|
||||||
@@ -87,10 +87,8 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Health check', { status, detailed });
|
|
||||||
|
|
||||||
return NextResponse.json(response, {
|
return NextResponse.json(response, {
|
||||||
status: httpStatus,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||||
'Pragma': 'no-cache',
|
'Pragma': 'no-cache',
|
||||||
@@ -116,13 +114,6 @@ export async function GET(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function HEAD() {
|
export async function HEAD() {
|
||||||
try {
|
try {
|
||||||
const memUsage = process.memoryUsage();
|
|
||||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
|
||||||
|
|
||||||
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
|
||||||
return new Response(null, { status: 503 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(null, { status: 503 });
|
return new Response(null, { status: 503 });
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
||||||
|
*
|
||||||
|
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
||||||
|
* No admin auth required - this is how regular users receive plugins/themes.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||||
|
getPluginRegistry(),
|
||||||
|
getThemeRegistry(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only serve enabled plugins
|
||||||
|
const plugins = pluginRegistry.plugins
|
||||||
|
.filter(p => p.enabled)
|
||||||
|
.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
version: p.version,
|
||||||
|
author: p.author,
|
||||||
|
description: p.description,
|
||||||
|
type: p.type,
|
||||||
|
permissions: p.permissions,
|
||||||
|
entrypoint: p.entrypoint,
|
||||||
|
forceEnabled: p.forceEnabled || false,
|
||||||
|
settingsSchema: undefined, // Will be read from the bundle's manifest
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Only serve enabled themes
|
||||||
|
const themes = themeRegistry.themes
|
||||||
|
.filter(t => t.enabled)
|
||||||
|
.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
version: t.version,
|
||||||
|
author: t.author,
|
||||||
|
description: t.description,
|
||||||
|
variants: t.variants,
|
||||||
|
forceEnabled: t.forceEnabled || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ plugins, themes },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const VALID_SIZES = new Set([192, 512]);
|
||||||
|
|
||||||
|
// Cache resized images in memory to avoid reprocessing on every request
|
||||||
|
const cache = new Map<number, Blob>();
|
||||||
|
|
||||||
|
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||||
|
// Absolute URL (http/https)
|
||||||
|
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||||
|
const res = await fetch(iconUrl);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch PWA icon: ${res.status}`);
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path relative to public/ directory
|
||||||
|
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||||
|
return readFile(publicPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ size: string }> }
|
||||||
|
) {
|
||||||
|
const { size: sizeParam } = await params;
|
||||||
|
const size = parseInt(sizeParam, 10);
|
||||||
|
|
||||||
|
if (!VALID_SIZES.has(size)) {
|
||||||
|
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
|
||||||
|
if (!iconUrl) {
|
||||||
|
return new NextResponse('No PWA icon configured', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngHeaders = {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cache.has(size)) {
|
||||||
|
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||||
|
const resized = await sharp(sourceBuffer)
|
||||||
|
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const ab = new ArrayBuffer(resized.byteLength);
|
||||||
|
new Uint8Array(ab).set(resized);
|
||||||
|
const blob = new Blob([ab], { type: 'image/png' });
|
||||||
|
cache.set(size, blob);
|
||||||
|
|
||||||
|
return new NextResponse(blob, { headers: pngHeaders });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate PWA icon:', err);
|
||||||
|
return new NextResponse('Failed to generate icon', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
-21
@@ -2,27 +2,91 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { decryptSession } from '@/lib/auth/crypto';
|
import { decryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
|
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
|
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
|
||||||
|
function classifyError(error: unknown): { message: string; status: number } {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
switch (code) {
|
||||||
|
case 'EACCES':
|
||||||
|
case 'EPERM':
|
||||||
|
return {
|
||||||
|
message: 'Write permission denied on settings data directory. Check filesystem permissions for the SETTINGS_DATA_DIR (or data/settings/).',
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
case 'EROFS':
|
||||||
|
return {
|
||||||
|
message: 'Filesystem is read-only. Settings cannot be saved. Ensure the data directory is on a writable volume.',
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
case 'ENOSPC':
|
||||||
|
return {
|
||||||
|
message: 'No disk space available to save settings.',
|
||||||
|
status: 507,
|
||||||
|
};
|
||||||
|
case 'ENOENT':
|
||||||
|
return {
|
||||||
|
message: 'Settings data directory does not exist and could not be created. Check SETTINGS_DATA_DIR configuration.',
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
if (msg.includes('SESSION_SECRET')) {
|
||||||
|
return {
|
||||||
|
message: 'Server configuration error: SESSION_SECRET is not set.',
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: `Internal server error: ${msg}`,
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isEnabled(): boolean {
|
function isEnabled(): boolean {
|
||||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
|
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip trailing slashes so differently-formatted URLs still match. */
|
||||||
|
function normalizeUrl(url: string): string {
|
||||||
|
return url.replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify identity against the session cookie if available.
|
* Verify identity against session cookies across all account slots.
|
||||||
* Returns true if no session cookie exists (can't verify) or if identity matches.
|
* With multi-account, the requesting account may be on any slot (0-4).
|
||||||
* Returns false if session cookie exists but identity doesn't match.
|
* Checks both basic-auth session cookies and stalwart auth context cookies
|
||||||
|
* (used by OAuth/SSO and TOTP-upgraded sessions).
|
||||||
|
* Returns true only if a matching cookie is found.
|
||||||
*/
|
*/
|
||||||
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const sessionToken = cookieStore.get(SESSION_COOKIE)?.value;
|
const normalizedServerUrl = normalizeUrl(serverUrl);
|
||||||
if (!sessionToken) return true; // No session cookie, can't verify (same-origin protection applies)
|
|
||||||
|
|
||||||
const session = decryptSession(sessionToken);
|
for (let slot = 0; slot <= 4; slot++) {
|
||||||
if (!session) return true; // Invalid session cookie, skip verification
|
// Check basic-auth session cookie
|
||||||
|
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||||
|
if (token) {
|
||||||
|
const session = decryptSession(token);
|
||||||
|
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return session.username === username && session.serverUrl === serverUrl;
|
// Check stalwart auth context cookie (set for all auth modes)
|
||||||
|
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||||
|
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No matching session found (or no cookies at all) → reject
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -42,13 +106,13 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await loadUserSettings(username, serverUrl);
|
const settings = await loadUserSettings(username, serverUrl);
|
||||||
if (!settings) {
|
return NextResponse.json({ settings: settings || null });
|
||||||
return NextResponse.json({ error: 'No settings found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
return NextResponse.json({ settings });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Settings load error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
logger.error('Settings load error', { error: message, code });
|
||||||
|
const classified = classifyError(error);
|
||||||
|
return NextResponse.json({ error: classified.message }, { status: classified.status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,11 +135,41 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveUserSettings(username, serverUrl, settings);
|
// Enforce admin policy - strip locked settings so users can't override them
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const policy = configManager.getPolicy();
|
||||||
|
const filteredSettings = { ...settings };
|
||||||
|
for (const key of Object.keys(filteredSettings)) {
|
||||||
|
const restriction = policy.restrictions[key];
|
||||||
|
if (!restriction) continue;
|
||||||
|
if (restriction.locked) {
|
||||||
|
delete filteredSettings[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = filteredSettings[key];
|
||||||
|
if (restriction.allowedValues && restriction.allowedValues.length > 0) {
|
||||||
|
if (!restriction.allowedValues.includes(value)) {
|
||||||
|
delete filteredSettings[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (restriction.min !== undefined && value < restriction.min) {
|
||||||
|
delete filteredSettings[key];
|
||||||
|
}
|
||||||
|
if (restriction.max !== undefined && value > restriction.max) {
|
||||||
|
delete filteredSettings[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveUserSettings(username, serverUrl, filteredSettings);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Settings save error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
logger.error('Settings save error', { error: message, code });
|
||||||
|
const classified = classifyError(error);
|
||||||
|
return NextResponse.json({ error: classified.message }, { status: classified.status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +191,10 @@ export async function DELETE(request: NextRequest) {
|
|||||||
await deleteUserSettings(username, serverUrl);
|
await deleteUserSettings(username, serverUrl);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Settings delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
logger.error('Settings delete error', { error: message, code });
|
||||||
|
const classified = classifyError(error);
|
||||||
|
return NextResponse.json({ error: classified.message }, { status: classified.status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-13
@@ -4,6 +4,32 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|||||||
|
|
||||||
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
|
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
|
||||||
|
|
||||||
|
function normalizeDavRelativePath(rawPath: string): string {
|
||||||
|
const sanitized = rawPath.replace(/\\/g, '/').split(/[?#]/, 1)[0] ?? '';
|
||||||
|
const segments = sanitized.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
return segments.map((segment) => {
|
||||||
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(segment);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid WebDAV path encoding');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0')) {
|
||||||
|
throw new Error('Invalid WebDAV path segment');
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeURIComponent(decoded);
|
||||||
|
}).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string): string {
|
||||||
|
const rootUrl = new URL(`${baseUrl.replace(/\/$/, '')}/dav/file/${encodeURIComponent(username)}/`);
|
||||||
|
const relativePath = normalizeDavRelativePath(rawPath);
|
||||||
|
return relativePath ? new URL(relativePath, rootUrl).toString() : rootUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/webdav
|
* POST /api/webdav
|
||||||
* Proxies WebDAV requests to the Stalwart server.
|
* Proxies WebDAV requests to the Stalwart server.
|
||||||
@@ -29,11 +55,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
||||||
const cleanPath = davPath.replace(/^\/+/, '');
|
const baseUrl = creds.serverUrl.replace(/\/$/, '');
|
||||||
const baseUrl = creds.apiUrl.replace(/\/$/, '');
|
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
|
||||||
const targetUrl = cleanPath
|
|
||||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
|
|
||||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
|
||||||
|
|
||||||
// Build headers for the upstream request
|
// Build headers for the upstream request
|
||||||
const upstreamHeaders: Record<string, string> = {
|
const upstreamHeaders: Record<string, string> = {
|
||||||
@@ -50,19 +73,20 @@ export async function POST(request: NextRequest) {
|
|||||||
// For MOVE/COPY, construct the full Destination URL from the relative path
|
// For MOVE/COPY, construct the full Destination URL from the relative path
|
||||||
const destination = request.headers.get('X-WebDAV-Destination');
|
const destination = request.headers.get('X-WebDAV-Destination');
|
||||||
if (destination) {
|
if (destination) {
|
||||||
const cleanDest = destination.replace(/^\/+/, '');
|
upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
|
||||||
upstreamHeaders['Destination'] = cleanDest
|
|
||||||
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
|
|
||||||
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const overwrite = request.headers.get('Overwrite');
|
const overwrite = request.headers.get('Overwrite');
|
||||||
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
|
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
|
||||||
|
|
||||||
// Forward request body for methods that need it
|
// Forward request body for methods that need it.
|
||||||
let body: ArrayBuffer | null = null;
|
// PUT streams directly to upstream to avoid buffering large uploads in memory.
|
||||||
if (method === 'PROPFIND' || method === 'PUT') {
|
// PROPFIND bodies are small XML and are read fully.
|
||||||
|
let body: ArrayBuffer | ReadableStream<Uint8Array> | null = null;
|
||||||
|
if (method === 'PROPFIND') {
|
||||||
body = await request.arrayBuffer();
|
body = await request.arrayBuffer();
|
||||||
|
} else if (method === 'PUT') {
|
||||||
|
body = request.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(targetUrl, {
|
const response = await fetch(targetUrl, {
|
||||||
@@ -70,7 +94,9 @@ export async function POST(request: NextRequest) {
|
|||||||
headers: upstreamHeaders,
|
headers: upstreamHeaders,
|
||||||
body,
|
body,
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
});
|
// `duplex: 'half'` is required by undici when sending a streaming request body.
|
||||||
|
...(method === 'PUT' ? { duplex: 'half' } : {}),
|
||||||
|
} as Parameters<typeof fetch>[1] & { duplex?: 'half' });
|
||||||
|
|
||||||
// For file downloads (GET), stream the response back
|
// For file downloads (GET), stream the response back
|
||||||
if (method === 'GET') {
|
if (method === 'GET') {
|
||||||
@@ -104,6 +130,10 @@ export async function POST(request: NextRequest) {
|
|||||||
status: response.status,
|
status: response.status,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('Invalid WebDAV path')) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
+237
-47
@@ -20,6 +20,41 @@
|
|||||||
--color-accent-foreground: #1e40af;
|
--color-accent-foreground: #1e40af;
|
||||||
--color-destructive: #ef4444;
|
--color-destructive: #ef4444;
|
||||||
--color-destructive-foreground: #ffffff;
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #0f172a;
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
--color-sidebar: #f8fafc;
|
||||||
|
--color-sidebar-foreground: #0f172a;
|
||||||
|
--color-sidebar-border: #e2e8f0;
|
||||||
|
--color-sidebar-accent: #f1f5f9;
|
||||||
|
--color-sidebar-accent-foreground: #0f172a;
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #0f172a;
|
||||||
|
|
||||||
|
/* Semantic status colors */
|
||||||
|
--color-success: #22c55e;
|
||||||
|
--color-success-foreground: #ffffff;
|
||||||
|
--color-warning: #eab308;
|
||||||
|
--color-warning-foreground: #ffffff;
|
||||||
|
--color-info: #3b82f6;
|
||||||
|
--color-info-foreground: #ffffff;
|
||||||
|
|
||||||
|
/* Selection states */
|
||||||
|
--color-selection: #dbeafe;
|
||||||
|
--color-selection-foreground: #1e40af;
|
||||||
|
|
||||||
|
/* Unread indicator */
|
||||||
|
--color-unread: #3b82f6;
|
||||||
|
|
||||||
|
/* Chart colors */
|
||||||
|
--color-chart-1: #3b82f6;
|
||||||
|
--color-chart-2: #22c55e;
|
||||||
|
--color-chart-3: #f59e0b;
|
||||||
|
--color-chart-4: #ef4444;
|
||||||
|
--color-chart-5: #8b5cf6;
|
||||||
|
|
||||||
/* Settings variables */
|
/* Settings variables */
|
||||||
--font-size-base: 16px;
|
--font-size-base: 16px;
|
||||||
@@ -50,6 +85,41 @@
|
|||||||
--color-accent-foreground: #dbeafe;
|
--color-accent-foreground: #dbeafe;
|
||||||
--color-destructive: #ef4444;
|
--color-destructive: #ef4444;
|
||||||
--color-destructive-foreground: #fafafa;
|
--color-destructive-foreground: #fafafa;
|
||||||
|
--color-popover: #1c1c1c;
|
||||||
|
--color-popover-foreground: #fafafa;
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
--color-sidebar: #0a0a0a;
|
||||||
|
--color-sidebar-foreground: #fafafa;
|
||||||
|
--color-sidebar-border: #262626;
|
||||||
|
--color-sidebar-accent: #1a1a1a;
|
||||||
|
--color-sidebar-accent-foreground: #fafafa;
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
--color-card: #141414;
|
||||||
|
--color-card-foreground: #fafafa;
|
||||||
|
|
||||||
|
/* Semantic status colors */
|
||||||
|
--color-success: #16a34a;
|
||||||
|
--color-success-foreground: #ffffff;
|
||||||
|
--color-warning: #ca8a04;
|
||||||
|
--color-warning-foreground: #ffffff;
|
||||||
|
--color-info: #60a5fa;
|
||||||
|
--color-info-foreground: #ffffff;
|
||||||
|
|
||||||
|
/* Selection states */
|
||||||
|
--color-selection: rgba(59, 130, 246, 0.25);
|
||||||
|
--color-selection-foreground: #93c5fd;
|
||||||
|
|
||||||
|
/* Unread indicator */
|
||||||
|
--color-unread: #60a5fa;
|
||||||
|
|
||||||
|
/* Chart colors */
|
||||||
|
--color-chart-1: #60a5fa;
|
||||||
|
--color-chart-2: #4ade80;
|
||||||
|
--color-chart-3: #fbbf24;
|
||||||
|
--color-chart-4: #f87171;
|
||||||
|
--color-chart-5: #a78bfa;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -68,6 +138,29 @@
|
|||||||
--color-accent-foreground: var(--color-accent-foreground);
|
--color-accent-foreground: var(--color-accent-foreground);
|
||||||
--color-destructive: var(--color-destructive);
|
--color-destructive: var(--color-destructive);
|
||||||
--color-destructive-foreground: var(--color-destructive-foreground);
|
--color-destructive-foreground: var(--color-destructive-foreground);
|
||||||
|
--color-popover: var(--color-popover);
|
||||||
|
--color-popover-foreground: var(--color-popover-foreground);
|
||||||
|
--color-sidebar: var(--color-sidebar);
|
||||||
|
--color-sidebar-foreground: var(--color-sidebar-foreground);
|
||||||
|
--color-sidebar-border: var(--color-sidebar-border);
|
||||||
|
--color-sidebar-accent: var(--color-sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--color-sidebar-accent-foreground);
|
||||||
|
--color-card: var(--color-card);
|
||||||
|
--color-card-foreground: var(--color-card-foreground);
|
||||||
|
--color-success: var(--color-success);
|
||||||
|
--color-success-foreground: var(--color-success-foreground);
|
||||||
|
--color-warning: var(--color-warning);
|
||||||
|
--color-warning-foreground: var(--color-warning-foreground);
|
||||||
|
--color-info: var(--color-info);
|
||||||
|
--color-info-foreground: var(--color-info-foreground);
|
||||||
|
--color-selection: var(--color-selection);
|
||||||
|
--color-selection-foreground: var(--color-selection-foreground);
|
||||||
|
--color-unread: var(--color-unread);
|
||||||
|
--color-chart-1: var(--color-chart-1);
|
||||||
|
--color-chart-2: var(--color-chart-2);
|
||||||
|
--color-chart-3: var(--color-chart-3);
|
||||||
|
--color-chart-4: var(--color-chart-4);
|
||||||
|
--color-chart-5: var(--color-chart-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -115,6 +208,15 @@ body {
|
|||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.email-content-text a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .email-content-text a {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
.email-content {
|
.email-content {
|
||||||
font-family:
|
font-family:
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
@@ -157,21 +259,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.email-content blockquote {
|
.email-content blockquote {
|
||||||
border-left: 3px solid #d1d5db;
|
border-left: 3px solid var(--color-border);
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
color: #4b5563;
|
color: var(--color-muted-foreground);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .email-content blockquote {
|
|
||||||
border-left-color: #4b5563;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-content pre {
|
.email-content pre {
|
||||||
background-color: #f5f5f7;
|
background-color: var(--color-muted);
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
||||||
@@ -180,23 +277,13 @@ body {
|
|||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .email-content pre {
|
|
||||||
background-color: #1f2937;
|
|
||||||
border-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-content code {
|
.email-content code {
|
||||||
background-color: #f3f4f6;
|
background-color: var(--color-muted);
|
||||||
padding: 0.125rem 0.375rem;
|
padding: 0.125rem 0.375rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #dc2626;
|
color: var(--color-destructive);
|
||||||
}
|
|
||||||
|
|
||||||
.dark .email-content code {
|
|
||||||
background-color: #374151;
|
|
||||||
color: #f87171;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-content h1,
|
.email-content h1,
|
||||||
@@ -276,26 +363,15 @@ body {
|
|||||||
.email-content table[border="1"] th,
|
.email-content table[border="1"] th,
|
||||||
.email-content table[border="1"] td {
|
.email-content table[border="1"] td {
|
||||||
padding: 0.625rem;
|
padding: 0.625rem;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--color-border);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-content table.data-table th,
|
.email-content table.data-table th,
|
||||||
.email-content table[border="1"] th {
|
.email-content table[border="1"] th {
|
||||||
background-color: #f9fafb;
|
background-color: var(--color-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #374151;
|
color: var(--color-foreground);
|
||||||
}
|
|
||||||
|
|
||||||
.dark .email-content table.data-table th,
|
|
||||||
.dark .email-content table[border="1"] th {
|
|
||||||
background-color: #1f2937;
|
|
||||||
color: #d1d5db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .email-content table.data-table td,
|
|
||||||
.dark .email-content table[border="1"] td {
|
|
||||||
border-color: #374151;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reset styles for layout tables (commonly used in HTML emails) */
|
/* Reset styles for layout tables (commonly used in HTML emails) */
|
||||||
@@ -317,28 +393,19 @@ body {
|
|||||||
|
|
||||||
.email-content hr {
|
.email-content hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid var(--color-border);
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .email-content hr {
|
|
||||||
border-top-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Email thread quoted text */
|
/* Email thread quoted text */
|
||||||
.email-content .quoted-text {
|
.email-content .quoted-text {
|
||||||
border-left: 3px solid #d1d5db;
|
border-left: 3px solid var(--color-border);
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
color: #4b5563;
|
color: var(--color-muted-foreground);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .email-content .quoted-text {
|
|
||||||
border-left-color: #4b5563;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toast animations */
|
/* Toast animations */
|
||||||
@keyframes toast-enter {
|
@keyframes toast-enter {
|
||||||
from {
|
from {
|
||||||
@@ -471,6 +538,37 @@ body {
|
|||||||
animation: slide-in-from-left 0.3s ease-out;
|
animation: slide-in-from-left 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Calendar swipe animations (subtler slide for view transitions) */
|
||||||
|
@keyframes cal-slide-in-right {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cal-slide-in-left {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-right {
|
||||||
|
animation: cal-slide-in-right 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-left {
|
||||||
|
animation: cal-slide-in-left 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reduced motion: respect user OS preference */
|
/* Reduced motion: respect user OS preference */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
@@ -490,3 +588,95 @@ body {
|
|||||||
-webkit-backdrop-filter: none !important;
|
-webkit-backdrop-filter: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TipTap Rich Text Editor */
|
||||||
|
.tiptap {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap li {
|
||||||
|
margin: 0.125rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap blockquote {
|
||||||
|
border-left: 3px solid var(--color-border);
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap pre {
|
||||||
|
background-color: var(--color-muted);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap code {
|
||||||
|
background-color: var(--color-muted);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap .ProseMirror-selectednode img {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|||||||
+19
-2
@@ -1,3 +1,20 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<!-- Generator: Gravit.io -->
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><path d=" M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z " fill="rgb(219,45,84)"/><path d=" M 944.774 332.832 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 658.833 589.547 L 944.774 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 55.226 332.832 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 341.167 589.547 L 55.226 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 765.645 857.641 C 701.996 901.327 612.26 941.932 500 990 Q 500 990 500 990 C 387.74 941.932 298.004 901.327 234.355 857.641 L 427.917 664.079 C 435.604 668.703 443.74 672.579 452.215 675.636 C 467.543 681.179 483.703 684.007 500 683.996 C 516.297 684.007 532.457 681.179 547.785 675.636 C 556.261 672.579 564.396 668.703 572.083 664.079 L 765.645 857.641 Z " fill="rgb(219,45,84)"/></g></svg>
|
<defs>
|
||||||
|
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
|
||||||
|
<style>
|
||||||
|
.icon-bg { fill: #ffffff; }
|
||||||
|
.icon-mark { fill: rgb(219,45,84); }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.icon-bg { fill: #18181b; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
|
||||||
|
<rect width="1000" height="1000" class="icon-bg"/>
|
||||||
|
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
|
||||||
|
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
|
||||||
|
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
|
||||||
|
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
+33
-4
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { getLocale } from "next-intl/server";
|
import { getLocale } from "next-intl/server";
|
||||||
|
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||||
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -14,10 +16,23 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: "Bulwark Webmail",
|
const faviconUrl = process.env.FAVICON_URL;
|
||||||
description: "Minimalist webmail client using JMAP protocol",
|
|
||||||
};
|
return {
|
||||||
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||||
|
description: "Minimalist webmail client using JMAP protocol",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "black-translucent",
|
||||||
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||||
|
},
|
||||||
|
formatDetection: {
|
||||||
|
telephone: false,
|
||||||
|
},
|
||||||
|
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
@@ -26,10 +41,22 @@ export default async function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const nonce = (await headers()).get("x-nonce") ?? "";
|
const nonce = (await headers()).get("x-nonce") ?? "";
|
||||||
|
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta
|
||||||
|
name="apple-mobile-web-app-title"
|
||||||
|
content={process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail"}
|
||||||
|
/>
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
{parentOrigin && (
|
||||||
|
<meta name="parent-origin" content={parentOrigin} />
|
||||||
|
)}
|
||||||
<script
|
<script
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
@@ -54,7 +81,9 @@ export default async function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
<ServiceWorkerRegistration />
|
||||||
{children}
|
{children}
|
||||||
|
<PWAInstallPrompt />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
const appName =
|
||||||
|
process.env.APP_NAME ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||||
|
"Bulwark Webmail";
|
||||||
|
|
||||||
|
const shortName = process.env.APP_SHORT_NAME || appName;
|
||||||
|
const description =
|
||||||
|
process.env.APP_DESCRIPTION ||
|
||||||
|
"A modern webmail client built for Stalwart Mail Server";
|
||||||
|
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
|
||||||
|
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
|
||||||
|
|
||||||
|
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
|
||||||
|
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
|
||||||
|
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
|
||||||
|
|
||||||
|
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||||
|
? [
|
||||||
|
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: appName,
|
||||||
|
short_name: shortName,
|
||||||
|
description,
|
||||||
|
start_url: "/",
|
||||||
|
scope: "/",
|
||||||
|
display: "standalone",
|
||||||
|
orientation: "portrait-primary",
|
||||||
|
theme_color: themeColor,
|
||||||
|
background_color: backgroundColor,
|
||||||
|
icons,
|
||||||
|
categories: ["productivity"],
|
||||||
|
screenshots: [
|
||||||
|
{ src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" },
|
||||||
|
{ src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
|
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/');
|
||||||
|
if (!isAdminRoute) {
|
||||||
|
window.location.href = "/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/'));
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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="/"
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Go home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, parseISO, isToday, isTomorrow } from "date-fns";
|
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
|
||||||
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
|
import { MapPin, Users } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { getEventDayBounds } from "@/lib/calendar-utils";
|
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ interface CalendarAgendaViewProps {
|
|||||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,11 +28,13 @@ interface DayGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarAgendaView({
|
export function CalendarAgendaView({
|
||||||
|
selectedDate,
|
||||||
events,
|
events,
|
||||||
calendars,
|
calendars,
|
||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
|
onContextMenuEvent,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
}: CalendarAgendaViewProps) {
|
}: CalendarAgendaViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
@@ -43,9 +46,12 @@ export function CalendarAgendaView({
|
|||||||
return map;
|
return map;
|
||||||
}, [calendars]);
|
}, [calendars]);
|
||||||
|
|
||||||
|
const todayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const sorted = [...events].sort((a, b) =>
|
const sorted = [...events].sort((a, b) =>
|
||||||
new Date(a.start).getTime() - new Date(b.start).getTime()
|
getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
const groups: DayGroup[] = [];
|
const groups: DayGroup[] = [];
|
||||||
@@ -69,10 +75,38 @@ export function CalendarAgendaView({
|
|||||||
} catch { /* skip invalid dates */ }
|
} catch { /* skip invalid dates */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Always include today's date in the groups so the view has a "Today" anchor
|
||||||
|
const todayKey = format(new Date(), "yyyy-MM-dd");
|
||||||
|
if (!groupMap.has(todayKey)) {
|
||||||
|
const todayGroup = { date: startOfDay(new Date()), dateKey: todayKey, events: [] as CalendarEvent[] };
|
||||||
|
groupMap.set(todayKey, todayGroup);
|
||||||
|
groups.push(todayGroup);
|
||||||
|
}
|
||||||
|
|
||||||
groups.sort((a, b) => a.date.getTime() - b.date.getTime());
|
groups.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||||
return groups;
|
return groups;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
|
// Auto-scroll to today's section on mount and when selectedDate changes to today
|
||||||
|
const scrollToToday = useCallback(() => {
|
||||||
|
if (todayRef.current) {
|
||||||
|
todayRef.current.scrollIntoView({ block: "start" });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Scroll to today on mount
|
||||||
|
const frame = requestAnimationFrame(scrollToToday);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [scrollToToday]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Scroll to today when selectedDate changes to today
|
||||||
|
if (isToday(selectedDate)) {
|
||||||
|
scrollToToday();
|
||||||
|
}
|
||||||
|
}, [selectedDate, scrollToToday]);
|
||||||
|
|
||||||
const formatDateHeader = (date: Date): string => {
|
const formatDateHeader = (date: Date): string => {
|
||||||
if (isToday(date)) return t("events.today_header");
|
if (isToday(date)) return t("events.today_header");
|
||||||
if (isTomorrow(date)) return t("events.tomorrow_header");
|
if (isTomorrow(date)) return t("events.tomorrow_header");
|
||||||
@@ -86,19 +120,10 @@ export function CalendarAgendaView({
|
|||||||
return format(date, "HH:mm");
|
return format(date, "HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (grouped.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground">
|
|
||||||
<CalendarIcon className="w-12 h-12 mb-3 opacity-30" />
|
|
||||||
<p className="text-sm">{t("events.no_events")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
||||||
{grouped.map((group) => (
|
{grouped.map((group) => (
|
||||||
<div key={group.dateKey}>
|
<div key={group.dateKey} ref={isToday(group.date) ? todayRef : undefined}>
|
||||||
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
|
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-sm font-medium",
|
"text-sm font-medium",
|
||||||
@@ -111,14 +136,19 @@ export function CalendarAgendaView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{group.events.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t("events.no_events")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{group.events.map((ev) => {
|
{group.events.map((ev) => {
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const calendar = calendarMap.get(calId);
|
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||||
const color = getEventColor(ev, calendar);
|
const color = getEventColor(ev, calendar);
|
||||||
const start = parseISO(ev.start);
|
const start = getEventStartDate(ev);
|
||||||
const durMin = parseDuration(ev.duration);
|
const durMin = parseDuration(ev.duration);
|
||||||
const end = new Date(start.getTime() + durMin * 60000);
|
const end = getEventEndDate(ev);
|
||||||
const locationName = ev.locations
|
const locationName = ev.locations
|
||||||
? Object.values(ev.locations)[0]?.name
|
? Object.values(ev.locations)[0]?.name
|
||||||
: null;
|
: null;
|
||||||
@@ -129,6 +159,7 @@ export function CalendarAgendaView({
|
|||||||
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
|
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onHoverLeave?.()}
|
onMouseLeave={() => onHoverLeave?.()}
|
||||||
|
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
||||||
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
|
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
|
||||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
>
|
>
|
||||||
@@ -176,6 +207,7 @@ export function CalendarAgendaView({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
import { useMemo, useEffect, useRef, useState } from "react";
|
import { useMemo, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, isToday, parseISO } from "date-fns";
|
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { Check } from "lucide-react";
|
||||||
|
import { EventCard } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
|
|
||||||
interface CalendarDayViewProps {
|
interface CalendarDayViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -17,9 +19,14 @@ interface CalendarDayViewProps {
|
|||||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
|
tasks?: CalendarTask[];
|
||||||
|
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR_HEIGHT = 64;
|
const HOUR_HEIGHT = 64;
|
||||||
@@ -32,9 +39,14 @@ export function CalendarDayView({
|
|||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
|
onContextMenuEvent,
|
||||||
|
onContextMenuEmpty,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
|
tasks,
|
||||||
|
onToggleTaskComplete,
|
||||||
}: CalendarDayViewProps) {
|
}: CalendarDayViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -58,13 +70,23 @@ export function CalendarDayView({
|
|||||||
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
||||||
if (!spansThisDay) return;
|
if (!spansThisDay) return;
|
||||||
|
|
||||||
if (ev.showWithoutTime) allDay.push(ev);
|
if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
|
||||||
else timed.push(ev);
|
else timed.push(ev);
|
||||||
} catch { /* skip invalid dates */ }
|
} catch { /* skip invalid dates */ }
|
||||||
});
|
});
|
||||||
return { timedEvents: timed, allDayEvents: allDay };
|
return { timedEvents: timed, allDayEvents: allDay };
|
||||||
}, [events, selectedDate]);
|
}, [events, selectedDate]);
|
||||||
|
|
||||||
|
const dayTasks = useMemo(() => {
|
||||||
|
if (!tasks?.length) return [];
|
||||||
|
return tasks.filter(task => {
|
||||||
|
if (!task.due) return false;
|
||||||
|
try {
|
||||||
|
return isSameDay(parseISO(task.due), selectedDate);
|
||||||
|
} catch { return false; }
|
||||||
|
});
|
||||||
|
}, [tasks, selectedDate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -99,6 +121,7 @@ export function CalendarDayView({
|
|||||||
created: t("notifications.event_created"),
|
created: t("notifications.event_created"),
|
||||||
error: t("notifications.event_error"),
|
error: t("notifications.event_error"),
|
||||||
},
|
},
|
||||||
|
isMobile,
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatHour = (h: number): string => {
|
const formatHour = (h: number): string => {
|
||||||
@@ -109,10 +132,10 @@ export function CalendarDayView({
|
|||||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
|
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents, selectedDate), [timedEvents, selectedDate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
<div className="flex min-h-0 flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
||||||
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
||||||
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
||||||
{isMobile
|
{isMobile
|
||||||
@@ -122,29 +145,74 @@ export function CalendarDayView({
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{allDayEvents.length > 0 && (
|
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
||||||
<div className="px-4 py-2 border-b border-border">
|
<div
|
||||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
className="px-4 py-2 border-b border-border"
|
||||||
<div className="space-y-1">
|
onContextMenu={onContextMenuEmpty ? (e) => {
|
||||||
{allDayEvents.map((ev) => {
|
if ((e.target as HTMLElement).closest("[data-calendar-event],button")) return;
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
onContextMenuEmpty(e, selectedDate, undefined, true);
|
||||||
return (
|
} : undefined}
|
||||||
<EventCard
|
>
|
||||||
key={ev.id}
|
{allDayEvents.length > 0 && (
|
||||||
event={ev}
|
<>
|
||||||
calendar={calendarMap.get(calId)}
|
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||||
variant="chip"
|
<div className="space-y-1">
|
||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
{allDayEvents.map((ev) => {
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
const calId = getPrimaryCalendarId(ev);
|
||||||
onMouseLeave={onHoverLeave}
|
return (
|
||||||
/>
|
<EventCard
|
||||||
);
|
key={ev.id}
|
||||||
})}
|
event={ev}
|
||||||
</div>
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
|
variant="chip"
|
||||||
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{dayTasks.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className={cn("text-[10px] text-muted-foreground mb-1", allDayEvents.length > 0 && "mt-2")}>{t("tasks.label")}</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{dayTasks.map((task) => {
|
||||||
|
const isCompleted = task.progress === "completed";
|
||||||
|
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||||
|
const color = cal?.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
style={{ borderLeft: `3px solid ${color}` }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggleTaskComplete?.(task); }}
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 w-3.5 h-3.5 rounded-full border flex items-center justify-center",
|
||||||
|
isCompleted
|
||||||
|
? "bg-success border-success text-success-foreground"
|
||||||
|
: "border-muted-foreground/40 hover:border-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCompleted && <Check className="h-2.5 w-2.5" />}
|
||||||
|
</button>
|
||||||
|
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
|
||||||
|
{task.title || t("tasks.no_title")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
@@ -180,19 +248,20 @@ export function CalendarDayView({
|
|||||||
aria-label={formatHour(h)}
|
aria-label={formatHour(h)}
|
||||||
onClick={() => handleSlotClick(selectedDate, h)}
|
onClick={() => handleSlotClick(selectedDate, h)}
|
||||||
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
|
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, selectedDate, h, false) : undefined}
|
||||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const start = parseISO(ev.start);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
|
||||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const isResizing = resizeVisual?.eventId === ev.id;
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const top = isResizing ? resizeVisual!.topPx : baseTop;
|
||||||
|
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
|
||||||
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
|
|
||||||
@@ -205,18 +274,29 @@ export function CalendarDayView({
|
|||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={ev}
|
event={ev}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
variant="block"
|
variant="block"
|
||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
draggable
|
draggable
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
data-resize-handle
|
||||||
|
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
|
aria-label={t("events.resize")}
|
||||||
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
|
||||||
|
onPointerMove={handleResizePointerMove}
|
||||||
|
onPointerUp={handleResizePointerUp}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
data-resize-handle
|
data-resize-handle
|
||||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
aria-label={t("events.resize")}
|
aria-label={t("events.resize")}
|
||||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||||
onPointerMove={handleResizePointerMove}
|
onPointerMove={handleResizePointerMove}
|
||||||
onPointerUp={handleResizePointerUp}
|
onPointerUp={handleResizePointerUp}
|
||||||
>
|
>
|
||||||
@@ -232,8 +312,8 @@ export function CalendarDayView({
|
|||||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-red-500 -ml-1" />
|
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ml-1" />
|
||||||
<div className="flex-1 h-px bg-red-500" />
|
<div className="flex-1 h-px bg-destructive" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -274,6 +354,34 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, selectedDate) && (
|
||||||
|
(() => {
|
||||||
|
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
|
||||||
|
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
|
||||||
|
const durationMin = Math.max(15, endMin - startMin);
|
||||||
|
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||||
|
const color = cal?.color || "hsl(var(--primary))";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute left-2 right-2 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
|
||||||
|
style={{
|
||||||
|
top: (startMin / 60) * HOUR_HEIGHT,
|
||||||
|
height: Math.max(24, (durationMin / 60) * HOUR_HEIGHT),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `${color}10`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
|
||||||
|
{pendingPreview.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
|
||||||
|
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
import { buildWeekSegments, getEventDayBounds } from "@/lib/calendar-utils";
|
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface CalendarMonthViewProps {
|
interface CalendarMonthViewProps {
|
||||||
@@ -22,8 +23,12 @@ interface CalendarMonthViewProps {
|
|||||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||||
|
onCreateAtTime?: (date: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarMonthView({
|
export function CalendarMonthView({
|
||||||
@@ -34,8 +39,12 @@ export function CalendarMonthView({
|
|||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
|
onContextMenuEvent,
|
||||||
|
onContextMenuEmpty,
|
||||||
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
}: CalendarMonthViewProps) {
|
}: CalendarMonthViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -116,13 +125,14 @@ export function CalendarMonthView({
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(json);
|
const data = JSON.parse(json);
|
||||||
const originalStart = parseISO(data.originalStart);
|
const originalStart = parseISO(data.originalStart);
|
||||||
|
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
||||||
|
const isAllDay = event?.showWithoutTime;
|
||||||
const newStart = new Date(day);
|
const newStart = new Date(day);
|
||||||
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
|
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
|
||||||
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
const newStartISO = isAllDay ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
||||||
if (newStartISO === data.originalStart) return;
|
if (newStartISO === data.originalStart) return;
|
||||||
const client = useAuthStore.getState().client;
|
const client = useAuthStore.getState().client;
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
|
||||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -165,6 +175,8 @@ export function CalendarMonthView({
|
|||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
aria-label={fullDateLabel}
|
aria-label={fullDateLabel}
|
||||||
onClick={() => onSelectDate(day)}
|
onClick={() => onSelectDate(day)}
|
||||||
|
onDoubleClick={() => onCreateAtTime?.(day)}
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
|
||||||
onDragOver={(e) => handleCellDragOver(e, key)}
|
onDragOver={(e) => handleCellDragOver(e, key)}
|
||||||
onDragLeave={handleCellDragLeave}
|
onDragLeave={handleCellDragLeave}
|
||||||
onDrop={(e) => handleCellDrop(e, day)}
|
onDrop={(e) => handleCellDrop(e, day)}
|
||||||
@@ -191,35 +203,67 @@ export function CalendarMonthView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
dayEvents.length > 0 && (
|
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
||||||
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
{dayEvents.slice(0, 3).map((ev) => {
|
||||||
{dayEvents.slice(0, 3).map((ev) => {
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const cal = calId ? calendarMap.get(calId) : undefined;
|
||||||
const cal = calendarMap.get(calId);
|
const evColor = ev.color || cal?.color || "#3b82f6";
|
||||||
const evColor = ev.color || cal?.color || "#3b82f6";
|
return (
|
||||||
return (
|
<span
|
||||||
<span
|
key={ev.id}
|
||||||
key={ev.id}
|
className="w-1.5 h-1.5 rounded-full"
|
||||||
className="w-1.5 h-1.5 rounded-full"
|
style={{ backgroundColor: evColor }}
|
||||||
style={{ backgroundColor: evColor }}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
{dayEvents.length > 3 && (
|
||||||
{dayEvents.length > 3 && (
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
)}
|
||||||
)}
|
{pendingPreview && isSameDay(pendingPreview.start, day) && (
|
||||||
</div>
|
<span
|
||||||
)
|
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
||||||
|
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && pendingPreview && (() => {
|
||||||
|
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
|
||||||
|
if (previewDayIdx === -1) return null;
|
||||||
|
const previewRow = rowCount;
|
||||||
|
const cal = calendarMap.get(pendingPreview.calendarId);
|
||||||
|
const color = cal?.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||||
|
<div
|
||||||
|
className="absolute px-0.5"
|
||||||
|
style={{
|
||||||
|
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
|
||||||
|
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
||||||
|
top: previewRow * 22,
|
||||||
|
height: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
|
||||||
|
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
|
||||||
|
>
|
||||||
|
{pendingPreview.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{!isMobile && segments.length > 0 && (
|
{!isMobile && segments.length > 0 && (
|
||||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||||
{segments.map((segment) => {
|
{segments.map((segment) => {
|
||||||
const calId = Object.keys(segment.event.calendarIds)[0];
|
const calId = getPrimaryCalendarId(segment.event);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
||||||
@@ -233,13 +277,14 @@ export function CalendarMonthView({
|
|||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={segment.event}
|
event={segment.event}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
variant="span"
|
variant="span"
|
||||||
continuesBefore={segment.continuesBefore}
|
continuesBefore={segment.continuesBefore}
|
||||||
continuesAfter={segment.continuesAfter}
|
continuesAfter={segment.continuesAfter}
|
||||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
draggable
|
draggable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Globe, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
|
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import type { JMAPClient } from "@/lib/jmap/client";
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||||
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
interface CalendarSidebarPanelProps {
|
interface CalendarSidebarPanelProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
selectedCalendarIds: string[];
|
selectedCalendarIds: string[];
|
||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
|
onShareCalendar?: (calendar: Calendar) => void;
|
||||||
|
onCreateEvent?: (calendar: Calendar) => void;
|
||||||
|
onClearCalendar?: (calendar: Calendar) => void;
|
||||||
|
onDeleteCalendar?: (calendar: Calendar) => void;
|
||||||
|
onCreateCalendar?: () => void;
|
||||||
onSubscribe?: () => void;
|
onSubscribe?: () => void;
|
||||||
client?: JMAPClient | null;
|
onEditSubscription?: (subscriptionId: string) => void;
|
||||||
|
client?: IJMAPClient | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarSidebarPanel({
|
export function CalendarSidebarPanel({
|
||||||
@@ -24,45 +35,49 @@ export function CalendarSidebarPanel({
|
|||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
|
onShareCalendar,
|
||||||
|
onCreateEvent,
|
||||||
|
onClearCalendar,
|
||||||
|
onDeleteCalendar,
|
||||||
|
onCreateCalendar,
|
||||||
onSubscribe,
|
onSubscribe,
|
||||||
|
onEditSubscription,
|
||||||
client,
|
client,
|
||||||
}: CalendarSidebarPanelProps) {
|
}: CalendarSidebarPanelProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const tSub = useTranslations("calendar.subscription");
|
const tSub = useTranslations("calendar.subscription");
|
||||||
|
const tMgmt = useTranslations("calendar.management");
|
||||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||||
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
||||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||||
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||||
|
const tasks = useTaskStore((s) => s.tasks);
|
||||||
|
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
||||||
|
|
||||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
const pendingTaskCount = useMemo(() => tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled').length, [tasks]);
|
||||||
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
const overdueTaskCount = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
||||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||||
if (!colorPickerId && !contextMenuCalId) return;
|
const sharedAccountGroups = useMemo(() => {
|
||||||
const handleClick = (e: MouseEvent) => {
|
const shared = calendars.filter(c => c.isShared);
|
||||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
|
const groups = new Map<string, { accountName: string; calendars: Calendar[] }>();
|
||||||
setColorPickerId(null);
|
for (const cal of shared) {
|
||||||
|
const key = cal.accountId || cal.accountName || cal.id;
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, { accountName: cal.accountName || key, calendars: [] });
|
||||||
}
|
}
|
||||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
groups.get(key)!.calendars.push(cal);
|
||||||
setContextMenuCalId(null);
|
}
|
||||||
}
|
return Array.from(groups.values());
|
||||||
};
|
}, [calendars]);
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setColorPickerId(null);
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClick);
|
|
||||||
document.addEventListener('keydown', handleKey);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClick);
|
|
||||||
document.removeEventListener('keydown', handleKey);
|
|
||||||
};
|
|
||||||
}, [colorPickerId, contextMenuCalId]);
|
|
||||||
|
|
||||||
const getSubscriptionForCalendar = (calendarId: string) => {
|
const getSubscriptionForCalendar = (calendarId: string) => {
|
||||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||||
@@ -71,7 +86,6 @@ export function CalendarSidebarPanel({
|
|||||||
const handleRefreshSubscription = async (subId: string) => {
|
const handleRefreshSubscription = async (subId: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
setRefreshingSubId(subId);
|
setRefreshingSubId(subId);
|
||||||
setContextMenuCalId(null);
|
|
||||||
try {
|
try {
|
||||||
await refreshICalSubscription(client, subId);
|
await refreshICalSubscription(client, subId);
|
||||||
toast.success(tSub('refresh_success'));
|
toast.success(tSub('refresh_success'));
|
||||||
@@ -84,7 +98,6 @@ export function CalendarSidebarPanel({
|
|||||||
|
|
||||||
const handleUnsubscribe = async (subId: string) => {
|
const handleUnsubscribe = async (subId: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
setContextMenuCalId(null);
|
|
||||||
try {
|
try {
|
||||||
await removeICalSubscription(client, subId);
|
await removeICalSubscription(client, subId);
|
||||||
toast.success(tSub('deleted'));
|
toast.success(tSub('deleted'));
|
||||||
@@ -95,108 +108,188 @@ export function CalendarSidebarPanel({
|
|||||||
|
|
||||||
if (calendars.length === 0 && !onSubscribe) return null;
|
if (calendars.length === 0 && !onSubscribe) return null;
|
||||||
|
|
||||||
|
const renderCalendarItem = (cal: Calendar) => {
|
||||||
|
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||||
|
const color = cal.color || "#3b82f6";
|
||||||
|
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={cal.id} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleVisibility(cal.id)}
|
||||||
|
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
||||||
|
"hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
|
||||||
|
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
||||||
|
)}
|
||||||
|
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
||||||
|
/>
|
||||||
|
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
||||||
|
{cal.name}
|
||||||
|
</span>
|
||||||
|
{isSubscriptionCalendar(cal.id) && (
|
||||||
|
<>
|
||||||
|
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
{refreshingSubId === getSubscriptionForCalendar(cal.id)?.id && (
|
||||||
|
<RefreshCw className="w-3 h-3 text-muted-foreground flex-shrink-0 animate-spin" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{cal.id === BIRTHDAY_CALENDAR_ID && (
|
||||||
|
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCalendarMenu = () => {
|
||||||
|
const cal = contextMenu.data;
|
||||||
|
if (!cal) return null;
|
||||||
|
|
||||||
|
if (isSubscriptionCalendar(cal.id)) {
|
||||||
|
const sub = getSubscriptionForCalendar(cal.id);
|
||||||
|
if (!sub || !client) return null;
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={tSub('edit')}
|
||||||
|
onClick={() => { closeContextMenu(); onEditSubscription?.(sub.id); }}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={RefreshCw}
|
||||||
|
label={tSub('refresh')}
|
||||||
|
onClick={() => { closeContextMenu(); handleRefreshSubscription(sub.id); }}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={tSub('unsubscribe')}
|
||||||
|
onClick={() => { closeContextMenu(); handleUnsubscribe(sub.id); }}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
{sub.lastRefreshed && (
|
||||||
|
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
||||||
|
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||||
|
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||||
|
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||||
|
const canChangeColor = !!onColorChange;
|
||||||
|
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||||
|
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||||
|
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
|
||||||
|
const color = cal.color || "#3b82f6";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||||
|
{canCreate && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Plus}
|
||||||
|
label={tMgmt('new_event_in_calendar')}
|
||||||
|
onClick={() => { closeContextMenu(); onCreateEvent(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Users}
|
||||||
|
label={tMgmt('share')}
|
||||||
|
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canChangeColor && (
|
||||||
|
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
|
||||||
|
<div className="px-2 py-1.5 w-[200px]">
|
||||||
|
<CalendarColorPicker
|
||||||
|
value={color}
|
||||||
|
onChange={(c) => { onColorChange(cal.id, c); closeContextMenu(); }}
|
||||||
|
allowCustom
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ContextMenuSubMenu>
|
||||||
|
)}
|
||||||
|
{showSeparator && <ContextMenuSeparator />}
|
||||||
|
{canClear && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Eraser}
|
||||||
|
label={tMgmt('clear_events')}
|
||||||
|
onClick={() => { closeContextMenu(); onClearCalendar(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={tMgmt('delete')}
|
||||||
|
onClick={() => { closeContextMenu(); onDeleteCalendar(cal); }}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
{enableCalendarTasks && (
|
||||||
{t("my_calendars")}
|
<button
|
||||||
</h3>
|
onClick={() => setViewMode('tasks')}
|
||||||
<div className="space-y-0.5">
|
className="flex items-center gap-2 w-full px-1.5 py-1.5 mb-3 rounded-md text-sm hover:bg-muted transition-colors"
|
||||||
{calendars.map((cal) => {
|
>
|
||||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
||||||
const color = cal.color || "#3b82f6";
|
<span>{t('tasks.label')}</span>
|
||||||
|
{pendingTaskCount > 0 && (
|
||||||
return (
|
<span className="ml-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
|
||||||
<div key={cal.id} className="relative">
|
)}
|
||||||
<button
|
{overdueTaskCount > 0 && (
|
||||||
onClick={() => onToggleVisibility(cal.id)}
|
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
|
||||||
onContextMenu={(e) => {
|
)}
|
||||||
e.preventDefault();
|
</button>
|
||||||
if (isSubscriptionCalendar(cal.id) && client) {
|
)}
|
||||||
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
|
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||||
setColorPickerId(null);
|
{onCreateCalendar ? (
|
||||||
} else if (onColorChange) {
|
<button
|
||||||
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
|
onClick={onCreateCalendar}
|
||||||
setContextMenuCalId(null);
|
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||||
}
|
title={tMgmt('add_calendar')}
|
||||||
}}
|
>
|
||||||
className={cn(
|
{t('my_calendars')}
|
||||||
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
"hover:bg-muted"
|
</button>
|
||||||
)}
|
) : (
|
||||||
>
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
<span
|
{t('my_calendars')}
|
||||||
className={cn(
|
</h3>
|
||||||
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
|
)}
|
||||||
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
|
||||||
)}
|
|
||||||
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
|
||||||
/>
|
|
||||||
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
|
||||||
{cal.name}
|
|
||||||
</span>
|
|
||||||
{isSubscriptionCalendar(cal.id) && (
|
|
||||||
<>
|
|
||||||
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
|
||||||
{refreshingSubId === getSubscriptionForCalendar(cal.id)?.id && (
|
|
||||||
<RefreshCw className="w-3 h-3 text-muted-foreground flex-shrink-0 animate-spin" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Subscription context menu on right-click */}
|
|
||||||
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
|
|
||||||
const sub = getSubscriptionForCalendar(cal.id);
|
|
||||||
if (!sub) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={contextMenuRef}
|
|
||||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRefreshSubscription(sub.id)}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
|
||||||
{tSub('refresh')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleUnsubscribe(sub.id)}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
{tSub('unsubscribe')}
|
|
||||||
</button>
|
|
||||||
{sub.lastRefreshed && (
|
|
||||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
|
||||||
{tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Color picker popover on right-click */}
|
|
||||||
{colorPickerId === cal.id && onColorChange && (
|
|
||||||
<div
|
|
||||||
ref={colorPickerRef}
|
|
||||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
|
||||||
>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
|
|
||||||
<CalendarColorPicker
|
|
||||||
value={color}
|
|
||||||
onChange={(c) => {
|
|
||||||
onColorChange(cal.id, c);
|
|
||||||
setColorPickerId(null);
|
|
||||||
}}
|
|
||||||
allowCustom
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{personalCalendars.map(renderCalendarItem)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sharedAccountGroups.map((group) => (
|
||||||
|
<div key={group.accountName} className="mt-4">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
|
||||||
|
<Share2 className="w-3 h-3" />
|
||||||
|
{group.accountName}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{group.calendars.map(renderCalendarItem)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{renderCalendarMenu()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft } from "lucide-react";
|
||||||
import { addDays, startOfWeek } from "date-fns";
|
import { addDays, startOfWeek } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||||
@@ -25,6 +25,7 @@ interface CalendarToolbarProps {
|
|||||||
calendars?: Calendar[];
|
calendars?: Calendar[];
|
||||||
selectedCalendarIds?: string[];
|
selectedCalendarIds?: string[];
|
||||||
onToggleVisibility?: (id: string) => void;
|
onToggleVisibility?: (id: string) => void;
|
||||||
|
enableCalendarTasks?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarToolbar({
|
export function CalendarToolbar({
|
||||||
@@ -39,13 +40,17 @@ export function CalendarToolbar({
|
|||||||
onSubscribe,
|
onSubscribe,
|
||||||
isMobile,
|
isMobile,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
|
onNavigateBack,
|
||||||
calendars,
|
calendars,
|
||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
|
enableCalendarTasks,
|
||||||
}: CalendarToolbarProps) {
|
}: CalendarToolbarProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const formatter = useFormatter();
|
const formatter = useFormatter();
|
||||||
const views: CalendarViewMode[] = ["month", "week", "day", "agenda"];
|
const views: CalendarViewMode[] = enableCalendarTasks
|
||||||
|
? ["month", "week", "day", "agenda", "tasks"]
|
||||||
|
: ["month", "week", "day", "agenda"];
|
||||||
const [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
|
const [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -86,13 +91,14 @@ export function CalendarToolbar({
|
|||||||
return isMobile
|
return isMobile
|
||||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
||||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||||
|
case "tasks":
|
||||||
|
return t("views.tasks");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showImportDropdown, setShowImportDropdown] = useState(false);
|
const [showImportDropdown, setShowImportDropdown] = useState(false);
|
||||||
const importDropdownRef = useRef<HTMLDivElement>(null);
|
const importDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [showViewDropdown, setShowViewDropdown] = useState(false);
|
|
||||||
const viewDropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showImportDropdown) return;
|
if (!showImportDropdown) return;
|
||||||
@@ -105,126 +111,185 @@ export function CalendarToolbar({
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [showImportDropdown]);
|
}, [showImportDropdown]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showViewDropdown) return;
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (viewDropdownRef.current && !viewDropdownRef.current.contains(e.target as Node)) {
|
|
||||||
setShowViewDropdown(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [showViewDropdown]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center gap-1.5 px-2 py-2 border-b border-border flex-wrap", !isMobile && "px-4 py-3 gap-2")}>
|
<div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
|
||||||
|
{/* ── MOBILE TOOLBAR ── */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex flex-col gap-1 px-2 py-2">
|
||||||
<button onClick={onPrev} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
|
{/* Row 1: Back / Date nav / Today */}
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<div className="flex items-center gap-1">
|
||||||
</button>
|
{onNavigateBack && (
|
||||||
<span className={cn("text-sm font-medium text-center min-w-[80px]")}>
|
<button
|
||||||
{getDateLabel()}
|
onClick={onNavigateBack}
|
||||||
</span>
|
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
||||||
<button onClick={onNext} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
|
aria-label={t("back_to_month")}
|
||||||
<ChevronRight className="w-4 h-4" />
|
>
|
||||||
</button>
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button onClick={onPrev} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-semibold text-center flex-1 select-none truncate">
|
||||||
|
{getDateLabel()}
|
||||||
|
</span>
|
||||||
|
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ml-0.5">
|
||||||
|
{t("views.today")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={onToday} className="touch-manipulation">
|
{/* Row 2: View switcher pills + calendar toggle */}
|
||||||
{t("views.today")}
|
<div className="flex items-center gap-1.5">
|
||||||
</Button>
|
<div className="flex flex-1 border border-border rounded-md overflow-hidden">
|
||||||
|
|
||||||
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
|
|
||||||
<div className="relative" ref={dropdownRef}>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowCalendarDropdown((v) => !v)}
|
|
||||||
aria-label={t("my_calendars")}
|
|
||||||
className="touch-manipulation"
|
|
||||||
>
|
|
||||||
<CalendarDays className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
{showCalendarDropdown && (
|
|
||||||
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
|
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
|
||||||
{t("my_calendars")}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{calendars.map((cal) => {
|
|
||||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
|
||||||
const color = cal.color || "#3b82f6";
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={cal.id}
|
|
||||||
onClick={() => onToggleVisibility(cal.id)}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
|
|
||||||
"hover:bg-muted"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
|
|
||||||
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
|
||||||
)}
|
|
||||||
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
|
||||||
/>
|
|
||||||
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
|
||||||
{cal.name}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isMobile && (
|
|
||||||
<div className="relative" ref={viewDropdownRef}>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowViewDropdown((v) => !v)}
|
|
||||||
className="touch-manipulation capitalize text-xs"
|
|
||||||
>
|
|
||||||
{t(`views.${viewMode}`)}
|
|
||||||
<ChevronLeft className="w-3 h-3 ml-1 rotate-[-90deg]" />
|
|
||||||
</Button>
|
|
||||||
{showViewDropdown && (
|
|
||||||
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-1 min-w-[120px]">
|
|
||||||
{views.map((v) => (
|
{views.map((v) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
onClick={() => { onViewModeChange(v); setShowViewDropdown(false); }}
|
onClick={() => onViewModeChange(v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center w-full px-3 py-2 rounded-md text-sm transition-colors touch-manipulation",
|
"flex-1 py-1.5 text-[11px] font-medium transition-colors touch-manipulation",
|
||||||
v === viewMode ? "bg-primary text-primary-foreground" : "hover:bg-muted text-foreground"
|
v === viewMode
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground active:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t(`views.${v}`)}
|
{t(`views.${v}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{calendars && selectedCalendarIds && onToggleVisibility && (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCalendarDropdown((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md border border-border transition-colors touch-manipulation",
|
||||||
|
showCalendarDropdown ? "bg-muted" : "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
aria-label={t("my_calendars")}
|
||||||
|
>
|
||||||
|
<CalendarDays className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{showCalendarDropdown && (
|
||||||
|
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||||
|
{t("my_calendars")}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{calendars.filter(c => !c.isShared).map((cal) => {
|
||||||
|
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||||
|
const color = cal.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cal.id}
|
||||||
|
onClick={() => onToggleVisibility(cal.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
|
||||||
|
"hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
|
||||||
|
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
||||||
|
)}
|
||||||
|
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
||||||
|
/>
|
||||||
|
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
||||||
|
{cal.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const shared = calendars.filter(c => c.isShared);
|
||||||
|
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
|
||||||
|
for (const c of shared) {
|
||||||
|
const key = c.accountId || c.accountName || c.id;
|
||||||
|
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
|
||||||
|
groups.get(key)!.cals.push(c);
|
||||||
|
}
|
||||||
|
return Array.from(groups.values()).map((group) => (
|
||||||
|
<div key={group.accountName} className="mt-2">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
|
||||||
|
{group.accountName}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{group.cals.map((cal) => {
|
||||||
|
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||||
|
const color = cal.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cal.id}
|
||||||
|
onClick={() => onToggleVisibility(cal.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
|
||||||
|
"hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
|
||||||
|
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
||||||
|
)}
|
||||||
|
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
||||||
|
/>
|
||||||
|
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
||||||
|
{cal.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── DESKTOP TOOLBAR ── */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
|
||||||
|
{t("views.today")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-base font-semibold ml-2 select-none">
|
||||||
|
{getDateLabel()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="flex border border-border rounded-md overflow-hidden">
|
<div className="flex h-8 border border-border rounded-md overflow-hidden">
|
||||||
{views.map((v) => (
|
{views.map((v) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
onClick={() => onViewModeChange(v)}
|
onClick={() => onViewModeChange(v)}
|
||||||
title={t(`views.${v}_hint`)}
|
title={t(`views.${v}_hint`)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 text-xs font-medium transition-colors",
|
"inline-flex items-center px-3 text-xs font-medium transition-colors",
|
||||||
v === viewMode
|
v === viewMode
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "hover:bg-muted text-muted-foreground"
|
: "hover:bg-muted text-muted-foreground"
|
||||||
@@ -238,7 +303,7 @@ export function CalendarToolbar({
|
|||||||
|
|
||||||
{(onImport || onSubscribe) && !isMobile && (
|
{(onImport || onSubscribe) && !isMobile && (
|
||||||
<div className="relative" ref={importDropdownRef}>
|
<div className="relative" ref={importDropdownRef}>
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowImportDropdown((v) => !v)}>
|
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
|
||||||
<Upload className="w-4 h-4 mr-1" />
|
<Upload className="w-4 h-4 mr-1" />
|
||||||
{t("import.title")}
|
{t("import.title")}
|
||||||
<ChevronDown className="w-3 h-3 ml-1" />
|
<ChevronDown className="w-3 h-3 ml-1" />
|
||||||
@@ -269,7 +334,7 @@ export function CalendarToolbar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Button size="sm" onClick={onCreateEvent}>
|
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
{t("events.create")}
|
{t("events.create")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
|
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { Check } from "lucide-react";
|
||||||
|
import { EventCard } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { buildWeekSegments, getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
|
|
||||||
interface CalendarWeekViewProps {
|
interface CalendarWeekViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -20,10 +22,15 @@ interface CalendarWeekViewProps {
|
|||||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
|
tasks?: CalendarTask[];
|
||||||
|
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR_HEIGHT = 60;
|
const HOUR_HEIGHT = 60;
|
||||||
@@ -37,24 +44,26 @@ export function CalendarWeekView({
|
|||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
|
onContextMenuEvent,
|
||||||
|
onContextMenuEmpty,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
|
tasks,
|
||||||
|
onToggleTaskComplete,
|
||||||
}: CalendarWeekViewProps) {
|
}: CalendarWeekViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
||||||
|
|
||||||
const weekDays = useMemo(() => {
|
const weekDays = useMemo(() => {
|
||||||
if (isMobile) {
|
|
||||||
// Show 3-day window centered on selected date
|
|
||||||
return Array.from({ length: 3 }, (_, i) => addDays(selectedDate, i - 1));
|
|
||||||
}
|
|
||||||
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
|
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
|
||||||
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
||||||
}, [selectedDate, weekStart, isMobile]);
|
}, [selectedDate, weekStart]);
|
||||||
|
|
||||||
const calendarMap = useMemo(() => {
|
const calendarMap = useMemo(() => {
|
||||||
const map = new Map<string, Calendar>();
|
const map = new Map<string, Calendar>();
|
||||||
@@ -72,7 +81,7 @@ export function CalendarWeekView({
|
|||||||
const cursor = new Date(startDay);
|
const cursor = new Date(startDay);
|
||||||
while (cursor <= endDay) {
|
while (cursor <= endDay) {
|
||||||
const key = format(cursor, "yyyy-MM-dd");
|
const key = format(cursor, "yyyy-MM-dd");
|
||||||
if (!ev.showWithoutTime) {
|
if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
|
||||||
const arr = timed.get(key) || [];
|
const arr = timed.get(key) || [];
|
||||||
arr.push(ev);
|
arr.push(ev);
|
||||||
timed.set(key, arr);
|
timed.set(key, arr);
|
||||||
@@ -84,24 +93,70 @@ export function CalendarWeekView({
|
|||||||
return timed;
|
return timed;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const allDaySegments = useMemo(() => buildWeekSegments(
|
const allDaySegments = useMemo(() => {
|
||||||
events.filter((event) => event.showWithoutTime),
|
const explicitAllDay = buildWeekSegmentsRaw(
|
||||||
weekDays,
|
events.filter((event) => event.showWithoutTime),
|
||||||
), [events, weekDays]);
|
weekDays,
|
||||||
|
);
|
||||||
|
const timedFullDay = buildTimedFullDayWeekSegments(
|
||||||
|
events.filter((event) => !event.showWithoutTime),
|
||||||
|
weekDays,
|
||||||
|
);
|
||||||
|
|
||||||
|
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
|
||||||
|
}, [events, weekDays]);
|
||||||
|
|
||||||
const allDayRowCount = useMemo(() => {
|
const allDayRowCount = useMemo(() => {
|
||||||
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||||
}, [allDaySegments]);
|
}, [allDaySegments]);
|
||||||
|
|
||||||
|
// Tasks grouped by day for the week
|
||||||
|
const tasksByDay = useMemo(() => {
|
||||||
|
if (!tasks?.length) return new Map<string, CalendarTask[]>();
|
||||||
|
const map = new Map<string, CalendarTask[]>();
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!task.due) continue;
|
||||||
|
try {
|
||||||
|
const key = format(parseISO(task.due), "yyyy-MM-dd");
|
||||||
|
const existing = map.get(key) || [];
|
||||||
|
existing.push(task);
|
||||||
|
map.set(key, existing);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
// Max tasks on any single day in this week
|
||||||
|
const taskRowCount = useMemo(() => {
|
||||||
|
let max = 0;
|
||||||
|
for (const day of weekDays) {
|
||||||
|
const key = format(day, "yyyy-MM-dd");
|
||||||
|
const count = tasksByDay.get(key)?.length ?? 0;
|
||||||
|
if (count > max) max = count;
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}, [tasksByDay, weekDays]);
|
||||||
|
|
||||||
const hasAllDay = useMemo(() => {
|
const hasAllDay = useMemo(() => {
|
||||||
return allDaySegments.length > 0;
|
return allDaySegments.length > 0 || taskRowCount > 0;
|
||||||
}, [allDaySegments]);
|
}, [allDaySegments, taskRowCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
|
scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
|
||||||
}
|
}
|
||||||
|
// On mobile, scroll horizontally to center today's column
|
||||||
|
if (isMobile && rootRef.current) {
|
||||||
|
const todayIdx = weekDays.findIndex(d => isToday(d));
|
||||||
|
if (todayIdx >= 0) {
|
||||||
|
const gutter = 40;
|
||||||
|
const colWidth = (rootRef.current.scrollWidth - gutter) / 7;
|
||||||
|
const target = gutter + todayIdx * colWidth - rootRef.current.clientWidth / 2 + colWidth / 2;
|
||||||
|
rootRef.current.scrollLeft = Math.max(0, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [nowMinutes, setNowMinutes] = useState(() => {
|
const [nowMinutes, setNowMinutes] = useState(() => {
|
||||||
@@ -130,6 +185,7 @@ export function CalendarWeekView({
|
|||||||
created: t("notifications.event_created"),
|
created: t("notifications.event_created"),
|
||||||
error: t("notifications.event_error"),
|
error: t("notifications.event_error"),
|
||||||
},
|
},
|
||||||
|
isMobile,
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatHour = (h: number): string => {
|
const formatHour = (h: number): string => {
|
||||||
@@ -140,29 +196,41 @@ export function CalendarWeekView({
|
|||||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
const colCount = isMobile ? 3 : 7;
|
const colCount = 7;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
|
<div
|
||||||
{hasAllDay && (
|
ref={rootRef}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 min-w-0 flex-col flex-1",
|
||||||
|
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
|
||||||
|
)}
|
||||||
|
role="grid"
|
||||||
|
aria-label={t("views.week")}
|
||||||
|
>
|
||||||
|
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
|
||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border">
|
||||||
<div
|
<div
|
||||||
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}
|
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
|
||||||
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
|
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||||
>
|
>
|
||||||
{t("events.all_day")}
|
{t("events.all_day")}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn("flex-1 relative grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}
|
className="flex-1 relative grid gap-px bg-border grid-cols-7"
|
||||||
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
|
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||||
>
|
>
|
||||||
{weekDays.map((day) => (
|
{weekDays.map((day) => (
|
||||||
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
|
<div
|
||||||
|
key={format(day, "yyyy-MM-dd")}
|
||||||
|
className="bg-background min-h-[28px]"
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
{allDaySegments.map((segment) => {
|
{allDaySegments.map((segment) => {
|
||||||
const calId = Object.keys(segment.event.calendarIds)[0];
|
const calId = getPrimaryCalendarId(segment.event);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
||||||
@@ -176,25 +244,69 @@ export function CalendarWeekView({
|
|||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={segment.event}
|
event={segment.event}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
variant="span"
|
variant="span"
|
||||||
continuesBefore={segment.continuesBefore}
|
continuesBefore={segment.continuesBefore}
|
||||||
continuesAfter={segment.continuesAfter}
|
continuesAfter={segment.continuesAfter}
|
||||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Task chips in all-day area */}
|
||||||
|
{taskRowCount > 0 && (
|
||||||
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: allDayRowCount * 24 + 2 }}>
|
||||||
|
{weekDays.map((day, dayIndex) => {
|
||||||
|
const key = format(day, "yyyy-MM-dd");
|
||||||
|
const dayTasks = tasksByDay.get(key) || [];
|
||||||
|
return dayTasks.map((task, taskIndex) => {
|
||||||
|
const isCompleted = task.progress === "completed";
|
||||||
|
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||||
|
const color = cal?.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`task-${task.id}`}
|
||||||
|
className="absolute px-0.5 pointer-events-auto"
|
||||||
|
style={{
|
||||||
|
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
|
||||||
|
width: `calc(${(1 / colCount) * 100}% - 2px)`,
|
||||||
|
top: taskIndex * 24,
|
||||||
|
height: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate flex items-center gap-1 cursor-pointer hover:opacity-80"
|
||||||
|
style={{ backgroundColor: `${color}20`, borderLeft: `3px solid ${color}` }}
|
||||||
|
onClick={() => onToggleTaskComplete?.(task)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"w-2.5 h-2.5 rounded-full border flex-shrink-0 flex items-center justify-center",
|
||||||
|
isCompleted ? "bg-success border-success" : "border-current"
|
||||||
|
)}>
|
||||||
|
{isCompleted && <Check className="h-2 w-2 text-white" />}
|
||||||
|
</span>
|
||||||
|
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex border-b border-border" role="row">
|
<div className="flex border-b border-border" role="row">
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")} />
|
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
|
||||||
<div className={cn("flex-1 border-l border-border", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
|
<div className="flex-1 border-l border-border grid grid-cols-7">
|
||||||
{weekDays.map((day) => {
|
{weekDays.map((day) => {
|
||||||
const todayCol = isToday(day);
|
const todayCol = isToday(day);
|
||||||
const selected = isSameDay(day, selectedDate);
|
const selected = isSameDay(day, selectedDate);
|
||||||
@@ -227,9 +339,9 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")}>
|
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
|
||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
@@ -245,12 +357,12 @@ export function CalendarWeekView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("flex-1 border-l border-border relative", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
|
<div className="flex-1 border-l border-border relative grid grid-cols-7">
|
||||||
{weekDays.map((day) => {
|
{weekDays.map((day) => {
|
||||||
const key = format(day, "yyyy-MM-dd");
|
const key = format(day, "yyyy-MM-dd");
|
||||||
const dayEvents = timedEvents.get(key) || [];
|
const dayEvents = timedEvents.get(key) || [];
|
||||||
const todayCol = isToday(day);
|
const todayCol = isToday(day);
|
||||||
const layouted = layoutOverlappingEvents(dayEvents);
|
const layouted = layoutOverlappingEvents(dayEvents, day);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -272,19 +384,20 @@ export function CalendarWeekView({
|
|||||||
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
|
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
|
||||||
onClick={() => handleSlotClick(day, h)}
|
onClick={() => handleSlotClick(day, h)}
|
||||||
onDoubleClick={() => handleSlotDoubleClick(day, h)}
|
onDoubleClick={() => handleSlotDoubleClick(day, h)}
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
|
||||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const start = parseISO(ev.start);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
|
||||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const isResizing = resizeVisual?.eventId === ev.id;
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const top = isResizing ? resizeVisual!.topPx : baseTop;
|
||||||
|
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
|
||||||
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
|
|
||||||
@@ -297,18 +410,29 @@ export function CalendarWeekView({
|
|||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={ev}
|
event={ev}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
variant="block"
|
variant="block"
|
||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
draggable
|
draggable
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
data-resize-handle
|
||||||
|
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
|
aria-label={t("events.resize")}
|
||||||
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
|
||||||
|
onPointerMove={handleResizePointerMove}
|
||||||
|
onPointerUp={handleResizePointerUp}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
data-resize-handle
|
data-resize-handle
|
||||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
aria-label={t("events.resize")}
|
aria-label={t("events.resize")}
|
||||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||||
onPointerMove={handleResizePointerMove}
|
onPointerMove={handleResizePointerMove}
|
||||||
onPointerUp={handleResizePointerUp}
|
onPointerUp={handleResizePointerUp}
|
||||||
>
|
>
|
||||||
@@ -324,8 +448,8 @@ export function CalendarWeekView({
|
|||||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-2 h-2 rounded-full bg-red-500 -ml-1" />
|
<div className="w-2 h-2 rounded-full bg-destructive -ml-1" />
|
||||||
<div className="flex-1 h-px bg-red-500" />
|
<div className="flex-1 h-px bg-destructive" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -366,6 +490,35 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
|
||||||
|
(() => {
|
||||||
|
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
|
||||||
|
let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
|
||||||
|
if (endMin <= startMin) endMin = 1440;
|
||||||
|
const durationMin = Math.max(15, endMin - startMin);
|
||||||
|
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||||
|
const color = cal?.color || "hsl(var(--primary))";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute left-1 right-1 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
|
||||||
|
style={{
|
||||||
|
top: (startMin / 60) * HOUR_HEIGHT,
|
||||||
|
height: Math.max(20, (durationMin / 60) * HOUR_HEIGHT),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `${color}10`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
|
||||||
|
{pendingPreview.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
|
||||||
|
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -373,5 +526,6 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X, Loader2, Calendar as CalendarIcon } from "lucide-react";
|
||||||
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
|
interface CreateCalendarModalProps {
|
||||||
|
client: IJMAPClient;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProps) {
|
||||||
|
const t = useTranslations("calendar.management");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const createCalendar = useCalendarStore((s) => s.createCalendar);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [color, setColor] = useState("#3b82f6");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const isValid = name.trim().length > 0;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const created = await createCalendar(client, { name: trimmed, color });
|
||||||
|
if (created) {
|
||||||
|
toast.success(t("calendar_created"));
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
toast.error(t("error_create"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("error_create"));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [name, color, client, createCalendar, onClose, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && !isSubmitting) onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [onClose, isSubmitting]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const modal = modalRef.current;
|
||||||
|
if (!modal) return;
|
||||||
|
const focusableEls = modal.querySelectorAll<HTMLElement>(
|
||||||
|
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
const firstEl = focusableEls[0];
|
||||||
|
const lastEl = focusableEls[focusableEls.length - 1];
|
||||||
|
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
if (e.shiftKey && document.activeElement === firstEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastEl?.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === lastEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstEl?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
modal.addEventListener("keydown", handler);
|
||||||
|
firstEl?.focus();
|
||||||
|
return () => modal.removeEventListener("keydown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-[1px]"
|
||||||
|
onClick={() => !isSubmitting && onClose()}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("add_calendar")}
|
||||||
|
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="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">{t("add_calendar")}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
aria-label={tCommon("close")}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
{t("name")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("name_placeholder")}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
{t("color")}
|
||||||
|
</label>
|
||||||
|
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||||
|
{tCommon("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
{tCommon("loading")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("create")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import { Plus, CalendarDays, CheckSquare, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmptySpaceContextMenuProps {
|
||||||
|
position: Position;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onNewEvent: () => void;
|
||||||
|
onNewAllDayEvent: () => void;
|
||||||
|
onNewTask?: () => void;
|
||||||
|
onGoToToday: () => void;
|
||||||
|
showAllDayOption?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptySpaceContextMenu({
|
||||||
|
position,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
menuRef,
|
||||||
|
onNewEvent,
|
||||||
|
onNewAllDayEvent,
|
||||||
|
onNewTask,
|
||||||
|
onGoToToday,
|
||||||
|
showAllDayOption = true,
|
||||||
|
}: EmptySpaceContextMenuProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
|
||||||
|
const handle = (fn: () => void) => () => {
|
||||||
|
fn();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuItem icon={Plus} label={t("events.new_event")} onClick={handle(onNewEvent)} />
|
||||||
|
{showAllDayOption && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={CalendarDays}
|
||||||
|
label={t("events.new_all_day_event")}
|
||||||
|
onClick={handle(onNewAllDayEvent)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onNewTask && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={CheckSquare}
|
||||||
|
label={t("events.new_task")}
|
||||||
|
onClick={handle(onNewTask)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem icon={Clock} label={t("events.go_to_today")} onClick={handle(onGoToToday)} />
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
|
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
event: CalendarEvent;
|
event: CalendarEvent;
|
||||||
@@ -15,6 +17,7 @@ interface EventCardProps {
|
|||||||
onClick?: (anchorRect: DOMRect) => void;
|
onClick?: (anchorRect: DOMRect) => void;
|
||||||
onMouseEnter?: (anchorRect: DOMRect) => void;
|
onMouseEnter?: (anchorRect: DOMRect) => void;
|
||||||
onMouseLeave?: () => void;
|
onMouseLeave?: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
continuesBefore?: boolean;
|
continuesBefore?: boolean;
|
||||||
@@ -63,16 +66,19 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = getEventStartDate(event);
|
||||||
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
|
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||||
|
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
|
|
||||||
const calendarName = calendar?.name || "";
|
const calendarName = calendar?.name || "";
|
||||||
const durationMinutes = parseDuration(event.duration);
|
const durationMinutes = parseDuration(event.duration);
|
||||||
const endTime = new Date(startDate.getTime() + durationMinutes * 60000);
|
const endTime = getEventEndDate(event);
|
||||||
const timeString = `${format(startDate, "HH:mm")} – ${format(endTime, "HH:mm")}`;
|
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
||||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||||
|
|
||||||
const handleDragStart = useCallback((e: DragEvent) => {
|
const handleDragStart = useCallback((e: DragEvent) => {
|
||||||
@@ -104,12 +110,15 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
"aria-roledescription": "draggable event",
|
"aria-roledescription": "draggable event",
|
||||||
} : {};
|
} : {};
|
||||||
|
|
||||||
|
const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, event) : undefined;
|
||||||
|
|
||||||
if (variant === "chip") {
|
if (variant === "chip") {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onMouseLeave?.()}
|
onMouseLeave={() => onMouseLeave?.()}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -137,6 +146,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onMouseLeave?.()}
|
onMouseLeave={() => onMouseLeave?.()}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -153,6 +163,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
|
{showTimeInMonthView && !event.showWithoutTime && (
|
||||||
|
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
|
||||||
|
)}
|
||||||
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
|
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -164,6 +177,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onMouseLeave?.()}
|
onMouseLeave={() => onMouseLeave?.()}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
data-calendar-event
|
data-calendar-event
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import {
|
||||||
|
Pencil,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
ClipboardCopy,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventContextMenuProps {
|
||||||
|
event: CalendarEvent;
|
||||||
|
position: Position;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDuplicate: () => void;
|
||||||
|
onExportICS: () => void;
|
||||||
|
onCopyTitle: () => void;
|
||||||
|
onCopyMeetingLink?: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventContextMenu({
|
||||||
|
event,
|
||||||
|
position,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
menuRef,
|
||||||
|
onEdit,
|
||||||
|
onDuplicate,
|
||||||
|
onExportICS,
|
||||||
|
onCopyTitle,
|
||||||
|
onCopyMeetingLink,
|
||||||
|
onDelete,
|
||||||
|
}: EventContextMenuProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
|
||||||
|
const handle = (fn: () => void) => () => {
|
||||||
|
fn();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMeetingLink = !!(
|
||||||
|
event.virtualLocations && Object.values(event.virtualLocations).some((v) => v.uri)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />
|
||||||
|
<ContextMenuItem icon={Copy} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Download}
|
||||||
|
label={t("events.export_ics")}
|
||||||
|
onClick={handle(onExportICS)}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={ClipboardCopy}
|
||||||
|
label={t("events.copy_title")}
|
||||||
|
onClick={handle(onCopyTitle)}
|
||||||
|
/>
|
||||||
|
{hasMeetingLink && onCopyMeetingLink && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={LinkIcon}
|
||||||
|
label={t("events.copy_link")}
|
||||||
|
onClick={handle(onCopyMeetingLink)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("events.delete")}
|
||||||
|
onClick={handle(onDelete)}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { format, parseISO } from "date-fns";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
|
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -87,7 +88,7 @@ function formatDurationDisplay(minutes: number): string {
|
|||||||
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||||
if (!event.alerts) return null;
|
if (!event.alerts) return null;
|
||||||
const first = Object.values(event.alerts)[0];
|
const first = Object.values(event.alerts)[0];
|
||||||
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
if (!first || !first.trigger || first.trigger["@type"] !== "OffsetTrigger") return null;
|
||||||
const offset = first.trigger.offset;
|
const offset = first.trigger.offset;
|
||||||
if (offset === "PT0S") return t("alerts.at_time");
|
if (offset === "PT0S") return t("alerts.at_time");
|
||||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
||||||
@@ -138,9 +139,9 @@ export function EventDetailPopover({
|
|||||||
const [isSavingNote, setIsSavingNote] = useState(false);
|
const [isSavingNote, setIsSavingNote] = useState(false);
|
||||||
|
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = getEventStartDate(event);
|
||||||
const durationMinutes = parseDuration(event.duration);
|
const durationMinutes = parseDuration(event.duration);
|
||||||
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
const endDate = getEventEndDate(event);
|
||||||
|
|
||||||
const locationName = useMemo(() => {
|
const locationName = useMemo(() => {
|
||||||
if (!event.locations) return null;
|
if (!event.locations) return null;
|
||||||
@@ -192,6 +193,10 @@ export function EventDetailPopover({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const tag = target?.tagName?.toLowerCase();
|
||||||
|
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
||||||
|
if (target?.getAttribute("contenteditable") === "true") return;
|
||||||
if (e.key === "e" && !noteExpanded) {
|
if (e.key === "e" && !noteExpanded) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onEdit();
|
onEdit();
|
||||||
@@ -293,7 +298,7 @@ export function EventDetailPopover({
|
|||||||
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
|
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
|
||||||
{calendar.name}
|
{calendar.name}
|
||||||
{event.status === "tentative" && (
|
{event.status === "tentative" && (
|
||||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
|
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
|
||||||
{t("detail.tentative")}
|
{t("detail.tentative")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -503,8 +508,8 @@ export function EventDetailPopover({
|
|||||||
onClick={() => onRsvp("accepted")}
|
onClick={() => onRsvp("accepted")}
|
||||||
className={
|
className={
|
||||||
userCurrentStatus === "accepted"
|
userCurrentStatus === "accepted"
|
||||||
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600"
|
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||||
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"
|
: "text-success border-success/30 hover:bg-success/10"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />}
|
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />}
|
||||||
@@ -516,8 +521,8 @@ export function EventDetailPopover({
|
|||||||
onClick={() => onRsvp("tentative")}
|
onClick={() => onRsvp("tentative")}
|
||||||
className={
|
className={
|
||||||
userCurrentStatus === "tentative"
|
userCurrentStatus === "tentative"
|
||||||
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600"
|
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||||
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"
|
: "border border-warning/30 text-warning hover:bg-warning/10"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />}
|
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />}
|
||||||
@@ -529,8 +534,8 @@ export function EventDetailPopover({
|
|||||||
onClick={() => onRsvp("declined")}
|
onClick={() => onRsvp("declined")}
|
||||||
className={
|
className={
|
||||||
userCurrentStatus === "declined"
|
userCurrentStatus === "declined"
|
||||||
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600"
|
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||||
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
|
: "text-destructive hover:bg-destructive/10"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />}
|
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />}
|
||||||
@@ -544,7 +549,7 @@ export function EventDetailPopover({
|
|||||||
<div className="px-4 py-2.5 border-t border-border flex items-center gap-1.5">
|
<div className="px-4 py-2.5 border-t border-border flex items-center gap-1.5">
|
||||||
{showDeleteConfirm ? (
|
{showDeleteConfirm ? (
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<span className="text-sm text-red-600 dark:text-red-400 flex-1">
|
<span className="text-sm text-destructive flex-1">
|
||||||
{t("form.delete_confirm")}
|
{t("form.delete_confirm")}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -607,9 +612,9 @@ function ParticipantStatusBadge({
|
|||||||
t: ReturnType<typeof useTranslations>;
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
accepted: "text-green-600 dark:text-green-400",
|
accepted: "text-success",
|
||||||
declined: "text-red-600 dark:text-red-400",
|
declined: "text-destructive",
|
||||||
tentative: "text-amber-600 dark:text-amber-400",
|
tentative: "text-warning",
|
||||||
"needs-action": "text-muted-foreground",
|
"needs-action": "text-muted-foreground",
|
||||||
};
|
};
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid
|
|||||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput } from "./participant-input";
|
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -18,17 +18,31 @@ import {
|
|||||||
getStatusCounts,
|
getStatusCounts,
|
||||||
buildParticipantMap,
|
buildParticipantMap,
|
||||||
} from "@/lib/calendar-participants";
|
} from "@/lib/calendar-participants";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface PendingEventPreview {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
title: string;
|
||||||
|
allDay: boolean;
|
||||||
|
calendarId: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface EventModalProps {
|
interface EventModalProps {
|
||||||
event?: CalendarEvent | null;
|
event?: CalendarEvent | null;
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
defaultDate?: Date;
|
defaultDate?: Date;
|
||||||
defaultEndDate?: Date;
|
defaultEndDate?: Date;
|
||||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void;
|
defaultAllDay?: boolean;
|
||||||
|
defaultCalendarId?: string;
|
||||||
|
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
||||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||||
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||||
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onPreviewChange?: (preview: PendingEventPreview | null) => void;
|
||||||
currentUserEmails?: string[];
|
currentUserEmails?: string[];
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
}
|
}
|
||||||
@@ -49,10 +63,12 @@ function buildDuration(startDate: Date, endDate: Date): string {
|
|||||||
const minutes = totalMinutes % 60;
|
const minutes = totalMinutes % 60;
|
||||||
let dur = "P";
|
let dur = "P";
|
||||||
if (days > 0) dur += `${days}D`;
|
if (days > 0) dur += `${days}D`;
|
||||||
dur += "T";
|
if (hours > 0 || minutes > 0) {
|
||||||
if (hours > 0) dur += `${hours}H`;
|
dur += "T";
|
||||||
if (minutes > 0) dur += `${minutes}M`;
|
if (hours > 0) dur += `${hours}H`;
|
||||||
if (dur === "PT") dur = "PT0M";
|
if (minutes > 0) dur += `${minutes}M`;
|
||||||
|
}
|
||||||
|
if (dur === "P") dur = "PT0M";
|
||||||
return dur;
|
return dur;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +89,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
|
|||||||
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
||||||
const offset = first.trigger.offset;
|
const offset = first.trigger.offset;
|
||||||
if (offset === "PT0S") return t("alerts.at_time");
|
if (offset === "PT0S") return t("alerts.at_time");
|
||||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
const minMatch = offset.match(/-?PT(\d+)M$/);
|
||||||
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
|
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
|
||||||
const hourMatch = offset.match(/-?PT?(\d+)H$/);
|
const hourMatch = offset.match(/-?PT(\d+)H$/);
|
||||||
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
|
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
|
||||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
const dayMatch = offset.match(/-?P(\d+)D/);
|
||||||
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
|
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
|
||||||
@@ -99,15 +115,20 @@ export function EventModal({
|
|||||||
calendars,
|
calendars,
|
||||||
defaultDate,
|
defaultDate,
|
||||||
defaultEndDate,
|
defaultEndDate,
|
||||||
|
defaultAllDay,
|
||||||
|
defaultCalendarId,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onRsvp,
|
onRsvp,
|
||||||
onClose,
|
onClose,
|
||||||
|
onPreviewChange,
|
||||||
currentUserEmails = [],
|
currentUserEmails = [],
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}: EventModalProps) {
|
}: EventModalProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
const isEdit = !!event;
|
const isEdit = !!event;
|
||||||
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
|
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
|
||||||
|
|
||||||
@@ -144,7 +165,7 @@ export function EventModal({
|
|||||||
}, [event, existingParticipants]);
|
}, [event, existingParticipants]);
|
||||||
|
|
||||||
const getInitialStart = (): Date => {
|
const getInitialStart = (): Date => {
|
||||||
if (event?.start) return parseISO(event.start);
|
if (event?.start) return getEventStartDate(event);
|
||||||
if (defaultDate) {
|
if (defaultDate) {
|
||||||
const d = new Date(defaultDate);
|
const d = new Date(defaultDate);
|
||||||
if (defaultEndDate) return d;
|
if (defaultEndDate) return d;
|
||||||
@@ -162,9 +183,7 @@ export function EventModal({
|
|||||||
if (event.showWithoutTime) {
|
if (event.showWithoutTime) {
|
||||||
return getEventDisplayEndDate(event);
|
return getEventDisplayEndDate(event);
|
||||||
}
|
}
|
||||||
const s = parseISO(event.start);
|
return getEventEndDate(event);
|
||||||
const dur = parseDuration(event.duration);
|
|
||||||
return new Date(s.getTime() + dur * 60000);
|
|
||||||
}
|
}
|
||||||
if (defaultEndDate) return new Date(defaultEndDate);
|
if (defaultEndDate) return new Date(defaultEndDate);
|
||||||
return addHours(getInitialStart(), 1);
|
return addHours(getInitialStart(), 1);
|
||||||
@@ -175,13 +194,17 @@ export function EventModal({
|
|||||||
const [location, setLocation] = useState(
|
const [location, setLocation] = useState(
|
||||||
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
||||||
);
|
);
|
||||||
|
const [virtualLocation, setVirtualLocation] = useState(
|
||||||
|
event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : ""
|
||||||
|
);
|
||||||
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
|
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
|
||||||
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
||||||
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
||||||
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
||||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false);
|
||||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||||
if (event?.calendarIds) return Object.keys(event.calendarIds)[0] || calendars[0]?.id || "";
|
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
||||||
|
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
|
||||||
const defaultCal = calendars.find(c => c.isDefault);
|
const defaultCal = calendars.find(c => c.isDefault);
|
||||||
return defaultCal?.id || calendars[0]?.id || "";
|
return defaultCal?.id || calendars[0]?.id || "";
|
||||||
});
|
});
|
||||||
@@ -196,9 +219,9 @@ export function EventModal({
|
|||||||
if (first.trigger["@type"] === "OffsetTrigger") {
|
if (first.trigger["@type"] === "OffsetTrigger") {
|
||||||
const offset = first.trigger.offset;
|
const offset = first.trigger.offset;
|
||||||
if (offset === "PT0S") return "at_time";
|
if (offset === "PT0S") return "at_time";
|
||||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
const minMatch = offset.match(/-?PT(\d+)M$/);
|
||||||
if (minMatch) return minMatch[1] as AlertOption;
|
if (minMatch) return minMatch[1] as AlertOption;
|
||||||
const hourMatch = offset.match(/-?PT?(\d+)H$/);
|
const hourMatch = offset.match(/-?PT(\d+)H$/);
|
||||||
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
|
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
|
||||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
const dayMatch = offset.match(/-?P(\d+)D/);
|
||||||
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
|
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
|
||||||
@@ -206,6 +229,7 @@ export function EventModal({
|
|||||||
return "none";
|
return "none";
|
||||||
});
|
});
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
|
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
|
||||||
if (!event?.participants) return [];
|
if (!event?.participants) return [];
|
||||||
@@ -214,6 +238,19 @@ export function EventModal({
|
|||||||
.map(p => ({ name: p.name, email: p.email }));
|
.map(p => ({ name: p.name, email: p.email }));
|
||||||
});
|
});
|
||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
const [sendInvitations, setSendInvitations] = useState(true);
|
||||||
|
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||||
|
|
||||||
|
// Report live preview to parent for grid outline
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onPreviewChange || isEdit) return;
|
||||||
|
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
|
||||||
|
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
|
||||||
|
const s = new Date(startStr);
|
||||||
|
const e = new Date(endStr);
|
||||||
|
if (isNaN(s.getTime()) || isNaN(e.getTime())) return;
|
||||||
|
onPreviewChange({ start: s, end: e, title: title || "(No title)", allDay, calendarId });
|
||||||
|
return () => onPreviewChange(null);
|
||||||
|
}, [startDate, startTime, endDate, endTime, allDay, title, calendarId, isEdit, onPreviewChange]);
|
||||||
|
|
||||||
const statusCounts = useMemo(() => {
|
const statusCounts = useMemo(() => {
|
||||||
if (!event?.participants) return null;
|
if (!event?.participants) return null;
|
||||||
@@ -228,16 +265,19 @@ export function EventModal({
|
|||||||
setAttendees(prev => prev.filter(a => a.email.toLowerCase() !== email.toLowerCase()));
|
setAttendees(prev => prev.filter(a => a.email.toLowerCase() !== email.toLowerCase()));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(async () => {
|
||||||
const trimmedTitle = title.trim();
|
const trimmedTitle = title.trim();
|
||||||
if (!trimmedTitle) return;
|
if (!trimmedTitle || isSaving) return;
|
||||||
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
||||||
|
|
||||||
|
const pendingAttendee = participantInputRef.current?.flush() ?? null;
|
||||||
|
const effectiveAttendees = pendingAttendee ? [...attendees, pendingAttendee] : attendees;
|
||||||
|
|
||||||
const startStr = allDay
|
const startStr = allDay
|
||||||
? `${startDate}T00:00:00`
|
? `${startDate}T00:00:00`
|
||||||
: `${startDate}T${startTime}:00`;
|
: `${startDate}T${startTime}:00`;
|
||||||
|
|
||||||
const start = new Date(startStr);
|
const start = allDay ? parseISO(startStr) : new Date(startStr);
|
||||||
let duration: string;
|
let duration: string;
|
||||||
|
|
||||||
if (allDay) {
|
if (allDay) {
|
||||||
@@ -270,6 +310,10 @@ export function EventModal({
|
|||||||
privacy: "public",
|
privacy: "public",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
data.uid = generateUUID();
|
||||||
|
}
|
||||||
|
|
||||||
if (location.trim()) {
|
if (location.trim()) {
|
||||||
data.locations = {
|
data.locations = {
|
||||||
loc1: {
|
loc1: {
|
||||||
@@ -287,6 +331,20 @@ export function EventModal({
|
|||||||
data.locations = null;
|
data.locations = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (virtualLocation.trim()) {
|
||||||
|
data.virtualLocations = {
|
||||||
|
vl1: {
|
||||||
|
"@type": "VirtualLocation",
|
||||||
|
name: null,
|
||||||
|
description: null,
|
||||||
|
uri: virtualLocation.trim(),
|
||||||
|
features: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (event && event.virtualLocations && Object.keys(event.virtualLocations).length > 0) {
|
||||||
|
data.virtualLocations = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (recurrence !== "none") {
|
if (recurrence !== "none") {
|
||||||
data.recurrenceRules = [{
|
data.recurrenceRules = [{
|
||||||
"@type": "RecurrenceRule",
|
"@type": "RecurrenceRule",
|
||||||
@@ -328,20 +386,27 @@ export function EventModal({
|
|||||||
data.alerts = null;
|
data.alerts = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attendees.length > 0 && currentUserEmails.length > 0) {
|
if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) {
|
||||||
const organizerEmail = currentUserEmails[0];
|
const organizerEmail = currentUserEmails[0];
|
||||||
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
|
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
|
||||||
data.participants = buildParticipantMap(
|
data.participants = buildParticipantMap(
|
||||||
{ name: organizerName, email: organizerEmail },
|
{ name: organizerName, email: organizerEmail },
|
||||||
attendees
|
effectiveAttendees
|
||||||
) as Record<string, CalendarParticipant>;
|
) as Record<string, CalendarParticipant>;
|
||||||
} else if (attendees.length === 0 && event?.participants) {
|
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
||||||
|
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||||
data.participants = null;
|
data.participants = null;
|
||||||
|
data.replyTo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||||
onSave(data, shouldSendScheduling);
|
setIsSaving(true);
|
||||||
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave]);
|
try {
|
||||||
|
await onSave(data, shouldSendScheduling);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||||
|
|
||||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!event || !userParticipantId || !onRsvp) return;
|
if (!event || !userParticipantId || !onRsvp) return;
|
||||||
@@ -351,12 +416,14 @@ export function EventModal({
|
|||||||
|
|
||||||
const handleDuplicate = useCallback(() => {
|
const handleDuplicate = useCallback(() => {
|
||||||
if (!event || !onDuplicate) return;
|
if (!event || !onDuplicate) return;
|
||||||
const start = parseISO(event.start);
|
const start = getEventStartDate(event);
|
||||||
const newStart = addDays(start, 1);
|
const newStart = addDays(start, 1);
|
||||||
|
const newUid = generateUUID();
|
||||||
const data: Partial<CalendarEvent> = {
|
const data: Partial<CalendarEvent> = {
|
||||||
|
uid: newUid,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
description: event.description,
|
description: event.description,
|
||||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
start: event.showWithoutTime ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||||
duration: event.duration,
|
duration: event.duration,
|
||||||
timeZone: event.timeZone,
|
timeZone: event.timeZone,
|
||||||
showWithoutTime: event.showWithoutTime,
|
showWithoutTime: event.showWithoutTime,
|
||||||
@@ -366,6 +433,7 @@ export function EventModal({
|
|||||||
privacy: event.privacy,
|
privacy: event.privacy,
|
||||||
};
|
};
|
||||||
if (event.locations) data.locations = structuredClone(event.locations);
|
if (event.locations) data.locations = structuredClone(event.locations);
|
||||||
|
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
|
||||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||||
if (event.participants) data.participants = structuredClone(event.participants);
|
if (event.participants) data.participants = structuredClone(event.participants);
|
||||||
@@ -419,9 +487,9 @@ export function EventModal({
|
|||||||
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
||||||
|
|
||||||
if (isAttendeeMode && event) {
|
if (isAttendeeMode && event) {
|
||||||
const startD = parseISO(event.start);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
const durMin = parseDuration(event.duration);
|
||||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
const endD = getEventEndDate(event);
|
||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
||||||
const participants = getParticipantList(event);
|
const participants = getParticipantList(event);
|
||||||
|
|
||||||
@@ -452,7 +520,7 @@ export function EventModal({
|
|||||||
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
|
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
|
||||||
{!event.showWithoutTime && (
|
{!event.showWithoutTime && (
|
||||||
<span className="text-muted-foreground ml-2">
|
<span className="text-muted-foreground ml-2">
|
||||||
{format(startD, "HH:mm")} – {format(endD, "HH:mm")}
|
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -493,8 +561,8 @@ export function EventModal({
|
|||||||
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||||
onClick={() => handleRsvp("accepted")}
|
onClick={() => handleRsvp("accepted")}
|
||||||
className={userCurrentStatus === "accepted"
|
className={userCurrentStatus === "accepted"
|
||||||
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600"
|
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||||
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"}
|
: "text-success border-success/30 hover:bg-success/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
||||||
{t("participants.accepted")}
|
{t("participants.accepted")}
|
||||||
@@ -504,8 +572,8 @@ export function EventModal({
|
|||||||
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||||
onClick={() => handleRsvp("tentative")}
|
onClick={() => handleRsvp("tentative")}
|
||||||
className={userCurrentStatus === "tentative"
|
className={userCurrentStatus === "tentative"
|
||||||
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600"
|
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||||
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"}
|
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
||||||
{t("participants.tentative")}
|
{t("participants.tentative")}
|
||||||
@@ -515,8 +583,8 @@ export function EventModal({
|
|||||||
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
||||||
onClick={() => handleRsvp("declined")}
|
onClick={() => handleRsvp("declined")}
|
||||||
className={userCurrentStatus === "declined"
|
className={userCurrentStatus === "declined"
|
||||||
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600"
|
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||||
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"}
|
: "text-destructive hover:bg-destructive/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
||||||
{t("participants.declined")}
|
{t("participants.declined")}
|
||||||
@@ -530,9 +598,9 @@ export function EventModal({
|
|||||||
|
|
||||||
// View mode: read-only display of event details with Edit button
|
// View mode: read-only display of event details with Edit button
|
||||||
if (mode === "view" && event) {
|
if (mode === "view" && event) {
|
||||||
const startD = parseISO(event.start);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
const durMin = parseDuration(event.duration);
|
||||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
const endD = getEventEndDate(event);
|
||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
||||||
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
||||||
const viewParticipants = getParticipantList(event);
|
const viewParticipants = getParticipantList(event);
|
||||||
@@ -576,7 +644,7 @@ export function EventModal({
|
|||||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{format(startD, "HH:mm")} – {format(endD, "HH:mm")}
|
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -664,8 +732,8 @@ export function EventModal({
|
|||||||
{onDelete && (
|
{onDelete && (
|
||||||
showDeleteConfirm ? (
|
showDeleteConfirm ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">{t("form.delete_confirm")}</span>
|
<span className="text-sm text-destructive">{t("form.delete_confirm")}</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700">
|
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||||
@@ -673,7 +741,7 @@ export function EventModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-red-600 dark:text-red-400">
|
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -698,7 +766,7 @@ export function EventModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
{isEdit ? t("events.edit") : t("events.create")}
|
{isEdit ? t("events.edit") : t("events.create")}
|
||||||
@@ -743,6 +811,39 @@ export function EventModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-1 block">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Video className="w-4 h-4" />
|
||||||
|
{t("form.meeting_link")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={virtualLocation}
|
||||||
|
onChange={(e) => setVirtualLocation(e.target.value)}
|
||||||
|
placeholder="https://meet.example.com/..."
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
<PluginSlot
|
||||||
|
name="calendar-event-actions"
|
||||||
|
className="mt-2 flex flex-wrap gap-2"
|
||||||
|
extraProps={{
|
||||||
|
eventData: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
start: startDate + 'T' + startTime,
|
||||||
|
end: endDate + 'T' + endTime,
|
||||||
|
isAllDay: allDay,
|
||||||
|
location,
|
||||||
|
virtualLocation,
|
||||||
|
calendarId,
|
||||||
|
},
|
||||||
|
setVirtualLocation,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">
|
<label className="text-sm font-medium mb-1 block">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
@@ -751,6 +852,7 @@ export function EventModal({
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<ParticipantInput
|
<ParticipantInput
|
||||||
|
ref={participantInputRef}
|
||||||
participants={attendees}
|
participants={attendees}
|
||||||
onAdd={handleAddAttendee}
|
onAdd={handleAddAttendee}
|
||||||
onRemove={handleRemoveAttendee}
|
onRemove={handleRemoveAttendee}
|
||||||
@@ -942,7 +1044,7 @@ export function EventModal({
|
|||||||
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
||||||
{t("form.cancel")}
|
{t("form.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={!title.trim()}>
|
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
|
||||||
{t("form.save")}
|
{t("form.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -960,9 +1062,9 @@ function StatusBadge({ status, isOrganizer, t }: {
|
|||||||
return <span className="text-xs text-primary">{t("participants.organizer")}</span>;
|
return <span className="text-xs text-primary">{t("participants.organizer")}</span>;
|
||||||
}
|
}
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
accepted: "text-green-600 dark:text-green-400",
|
accepted: "text-success",
|
||||||
declined: "text-red-600 dark:text-red-400",
|
declined: "text-destructive",
|
||||||
tentative: "text-amber-600 dark:text-amber-400",
|
tentative: "text-warning",
|
||||||
"needs-action": "text-muted-foreground",
|
"needs-action": "text-muted-foreground",
|
||||||
};
|
};
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
|
|||||||
@@ -6,17 +6,20 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import type { JMAPClient } from "@/lib/jmap/client";
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { apiFetch } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
interface ICalImportModalProps {
|
interface ICalImportModalProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
client: JMAPClient;
|
client: IJMAPClient;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||||
|
|
||||||
type ImportStep = "select" | "preview" | "importing";
|
type ImportStep = "select" | "preview" | "importing";
|
||||||
@@ -28,6 +31,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const tForm = useTranslations("calendar.form");
|
const tForm = useTranslations("calendar.form");
|
||||||
const importEvents = useCalendarStore((s) => s.importEvents);
|
const importEvents = useCalendarStore((s) => s.importEvents);
|
||||||
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
|
||||||
const [step, setStep] = useState<ImportStep>("select");
|
const [step, setStep] = useState<ImportStep>("select");
|
||||||
const [parsedEvents, setParsedEvents] = useState<Partial<CalendarEvent>[]>([]);
|
const [parsedEvents, setParsedEvents] = useState<Partial<CalendarEvent>[]>([]);
|
||||||
@@ -123,7 +127,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
setIsParsing(true);
|
setIsParsing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/fetch-ical", {
|
const response = await apiFetch("/api/fetch-ical", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ url: trimmed }),
|
body: JSON.stringify({ url: trimmed }),
|
||||||
@@ -193,10 +197,11 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
||||||
if (!event.start) return "";
|
if (!event.start) return "";
|
||||||
try {
|
try {
|
||||||
const date = parseISO(event.start);
|
const date = getEventStartDate(event as CalendarEvent);
|
||||||
|
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
return event.showWithoutTime
|
return event.showWithoutTime
|
||||||
? format(date, "MMM d, yyyy")
|
? format(date, "MMM d, yyyy")
|
||||||
: format(date, "MMM d, yyyy HH:mm");
|
: format(date, `MMM d, yyyy ${timeFmt}`);
|
||||||
} catch {
|
} catch {
|
||||||
return event.start;
|
return event.start;
|
||||||
}
|
}
|
||||||
@@ -346,7 +351,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-md px-3 py-2">
|
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,25 +4,29 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X, Loader2, Globe } from "lucide-react";
|
import { X, Loader2, Globe } from "lucide-react";
|
||||||
import type { JMAPClient } from "@/lib/jmap/client";
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore, type ICalSubscription } from "@/stores/calendar-store";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface ICalSubscriptionModalProps {
|
interface ICalSubscriptionModalProps {
|
||||||
client: JMAPClient;
|
client: IJMAPClient;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
editSubscription?: ICalSubscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
|
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
|
||||||
const t = useTranslations("calendar.subscription");
|
const t = useTranslations("calendar.subscription");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
||||||
|
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
|
||||||
|
|
||||||
const [url, setUrl] = useState("");
|
const isEdit = !!editSubscription;
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [color, setColor] = useState("#3b82f6");
|
const [url, setUrl] = useState(editSubscription?.url || "");
|
||||||
const [refreshInterval, setRefreshInterval] = useState(60);
|
const [name, setName] = useState(editSubscription?.name || "");
|
||||||
|
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
|
||||||
|
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -49,15 +53,26 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
|
if (isEdit && editSubscription) {
|
||||||
if (subscription) {
|
const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
|
||||||
toast.success(t("success", { name: name.trim() }));
|
if (trimmedUrl !== editSubscription.url) updates.url = trimmedUrl;
|
||||||
|
if (name.trim() !== editSubscription.name) updates.name = name.trim();
|
||||||
|
if (color !== editSubscription.color) updates.color = color;
|
||||||
|
if (refreshInterval !== editSubscription.refreshInterval) updates.refreshInterval = refreshInterval;
|
||||||
|
await updateICalSubscription(client, editSubscription.id, updates);
|
||||||
|
toast.success(t("updated", { name: name.trim() }));
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
setError(t("error"));
|
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
|
||||||
|
if (subscription) {
|
||||||
|
toast.success(t("success", { name: name.trim() }));
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(t("error"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("error"));
|
setError(isEdit ? t("update_error") : t("error"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -108,7 +123,7 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Globe className="w-5 h-5 text-primary" />
|
<Globe className="w-5 h-5 text-primary" />
|
||||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
<h2 className="text-lg font-semibold">{isEdit ? t("edit_title") : t("title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
{t("subscribing")}
|
{isEdit ? t("saving") : t("subscribing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
t("subscribe")
|
isEdit ? t("save") : t("subscribe")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, Fragment } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||||
eachDayOfInterval, getMonth, getYear,
|
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
||||||
isSameDay, isSameMonth, isToday, format,
|
isSameDay, isSameMonth, isToday, format,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
type PickerView = "days" | "months" | "years";
|
type PickerView = "days" | "months" | "years";
|
||||||
@@ -26,6 +27,7 @@ interface MiniCalendarProps {
|
|||||||
onChangeMonth: (date: Date) => void;
|
onChangeMonth: (date: Date) => void;
|
||||||
events?: CalendarEvent[];
|
events?: CalendarEvent[];
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
|
showWeekNumbers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MiniCalendar({
|
export function MiniCalendar({
|
||||||
@@ -35,6 +37,7 @@ export function MiniCalendar({
|
|||||||
onChangeMonth,
|
onChangeMonth,
|
||||||
events = [],
|
events = [],
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
|
showWeekNumbers = false,
|
||||||
}: MiniCalendarProps) {
|
}: MiniCalendarProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -52,7 +55,14 @@ export function MiniCalendar({
|
|||||||
const eventDates = useMemo(() => {
|
const eventDates = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
events.forEach(e => {
|
events.forEach(e => {
|
||||||
try { set.add(format(new Date(e.start), "yyyy-MM-dd")); } catch { /* skip */ }
|
try {
|
||||||
|
const { startDay, endDay } = getEventDayBounds(e);
|
||||||
|
const cursor = new Date(startDay);
|
||||||
|
while (cursor <= endDay) {
|
||||||
|
set.add(format(cursor, "yyyy-MM-dd"));
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
});
|
});
|
||||||
return set;
|
return set;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
@@ -61,6 +71,17 @@ export function MiniCalendar({
|
|||||||
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
||||||
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||||
|
|
||||||
|
// Compute week numbers for each row (one per 7-day chunk)
|
||||||
|
const weekNumbers = useMemo(() => {
|
||||||
|
if (!showWeekNumbers) return [];
|
||||||
|
const nums: number[] = [];
|
||||||
|
for (let i = 0; i < days.length; i += 7) {
|
||||||
|
// Use the first day of each row to determine the week number
|
||||||
|
nums.push(weekStart === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
|
||||||
|
}
|
||||||
|
return nums;
|
||||||
|
}, [days, showWeekNumbers, weekStart]);
|
||||||
|
|
||||||
const currentYear = getYear(displayMonth);
|
const currentYear = getYear(displayMonth);
|
||||||
const currentMonth = getMonth(displayMonth);
|
const currentMonth = getMonth(displayMonth);
|
||||||
const decadeStart = Math.floor(currentYear / 10) * 10;
|
const decadeStart = Math.floor(currentYear / 10) * 10;
|
||||||
@@ -135,35 +156,49 @@ export function MiniCalendar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pickerView === "days" && (
|
{pickerView === "days" && (
|
||||||
<div className="grid grid-cols-7 gap-0">
|
<div className={cn("grid gap-0", showWeekNumbers ? "grid-cols-[auto_repeat(7,1fr)]" : "grid-cols-7")}>
|
||||||
|
{showWeekNumbers && (
|
||||||
|
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
|
||||||
|
)}
|
||||||
{dayHeaders.map((d) => (
|
{dayHeaders.map((d) => (
|
||||||
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
|
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
|
||||||
{t(`days.${d}`)}
|
{t(`days.${d}`)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{days.map((day) => {
|
{days.map((day, index) => {
|
||||||
const inMonth = isSameMonth(day, displayMonth);
|
const inMonth = isSameMonth(day, displayMonth);
|
||||||
const selected = isSameDay(day, selectedDate);
|
const selected = isSameDay(day, selectedDate);
|
||||||
const today = isToday(day);
|
const today = isToday(day);
|
||||||
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
||||||
|
const isFirstDayOfRow = index % 7 === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Fragment key={day.toISOString()}>
|
||||||
key={day.toISOString()}
|
{showWeekNumbers && isFirstDayOfRow && (
|
||||||
onClick={() => onSelectDate(day)}
|
<div
|
||||||
className={cn(
|
key={`wk-${index}`}
|
||||||
"relative flex items-center justify-center w-7 h-7 text-xs rounded-full transition-colors",
|
className="flex items-center justify-center w-5 text-[9px] text-muted-foreground/60 font-medium"
|
||||||
!inMonth && "text-muted-foreground/40",
|
>
|
||||||
inMonth && !selected && "hover:bg-muted",
|
{weekNumbers[index / 7]}
|
||||||
today && !selected && "font-bold text-primary",
|
</div>
|
||||||
selected && "bg-primary text-primary-foreground"
|
|
||||||
)}
|
)}
|
||||||
>
|
<button
|
||||||
{format(day, "d")}
|
key={`day-${day.toISOString()}`}
|
||||||
{hasEvent && !selected && (
|
onClick={() => onSelectDate(day)}
|
||||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
className={cn(
|
||||||
)}
|
"relative flex items-center justify-center w-7 h-7 text-xs rounded-full transition-colors",
|
||||||
</button>
|
!inMonth && "text-muted-foreground/40",
|
||||||
|
inMonth && !selected && "hover:bg-muted",
|
||||||
|
today && !selected && "font-bold text-primary",
|
||||||
|
selected && "bg-primary text-primary-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{format(day, "d")}
|
||||||
|
{hasEvent && !selected && (
|
||||||
|
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -18,9 +18,13 @@ interface ParticipantInputProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParticipantInputHandle {
|
||||||
|
flush: () => Participant | null;
|
||||||
|
}
|
||||||
|
|
||||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export function ParticipantInput({ participants, onAdd, onRemove, disabled }: ParticipantInputProps) {
|
export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantInputProps>(function ParticipantInput({ participants, onAdd, onRemove, disabled }, ref) {
|
||||||
const t = useTranslations("calendar.participants");
|
const t = useTranslations("calendar.participants");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<Participant[]>([]);
|
const [suggestions, setSuggestions] = useState<Participant[]>([]);
|
||||||
@@ -86,8 +90,26 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
|||||||
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
||||||
|
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed && EMAIL_REGEX.test(trimmed)) {
|
||||||
|
addParticipant({ name: "", email: trimmed });
|
||||||
|
}
|
||||||
setTimeout(() => setShowSuggestions(false), 200);
|
setTimeout(() => setShowSuggestions(false), 200);
|
||||||
}, []);
|
}, [query, addParticipant]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
flush: () => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed || !EMAIL_REGEX.test(trimmed)) return null;
|
||||||
|
if (participants.some(e => e.email.toLowerCase() === trimmed.toLowerCase())) return null;
|
||||||
|
const p = { name: "", email: trimmed };
|
||||||
|
onAdd(p);
|
||||||
|
setQuery("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
}), [query, participants, onAdd]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -143,7 +165,22 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
|||||||
key={`${p.email}-${i}`}
|
key={`${p.email}-${i}`}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
|
||||||
>
|
>
|
||||||
<span className="truncate">{p.name || p.email}</span>
|
{!disabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onRemove(p.email);
|
||||||
|
setQuery(p.email);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}}
|
||||||
|
className="truncate hover:underline focus:outline-none focus:underline cursor-text"
|
||||||
|
aria-label={`${t("edit")} ${p.name || p.email}`}
|
||||||
|
>
|
||||||
|
{p.name || p.email}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">{p.name || p.email}</span>
|
||||||
|
)}
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -160,4 +197,4 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useCallback, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { format, parseISO, isPast, isToday, isTomorrow } from "date-fns";
|
||||||
|
import { Check, Flag, CalendarDays, ListTodo, Plus } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { CalendarTask, Calendar } from "@/lib/jmap/types";
|
||||||
|
import type { TaskViewFilter } from "@/stores/task-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
|
interface TaskListViewProps {
|
||||||
|
tasks: CalendarTask[];
|
||||||
|
calendars: Calendar[];
|
||||||
|
selectedCalendarIds: string[];
|
||||||
|
filter: TaskViewFilter;
|
||||||
|
showCompleted: boolean;
|
||||||
|
onSelectTask: (task: CalendarTask) => void;
|
||||||
|
onToggleComplete: (task: CalendarTask) => void;
|
||||||
|
selectedTaskId?: string | null;
|
||||||
|
onQuickCreate?: (title: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskPriorityIcon(priority: number) {
|
||||||
|
if (priority >= 1 && priority <= 4) return <Flag className="h-3.5 w-3.5 text-red-500" />;
|
||||||
|
if (priority === 5) return <Flag className="h-3.5 w-3.5 text-orange-500" />;
|
||||||
|
if (priority >= 6 && priority <= 9) return <Flag className="h-3.5 w-3.5 text-gray-400" />;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<typeof useTranslations>, timeFormat: string): { label: string; className: string } {
|
||||||
|
const dueDate = parseISO(due);
|
||||||
|
const overdue = isPast(dueDate) && !isToday(dueDate);
|
||||||
|
|
||||||
|
if (isToday(dueDate)) {
|
||||||
|
return {
|
||||||
|
label: t("tasks.due_today"),
|
||||||
|
className: "text-blue-600 dark:text-blue-400",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isTomorrow(dueDate)) {
|
||||||
|
return {
|
||||||
|
label: t("tasks.due_tomorrow"),
|
||||||
|
className: "text-muted-foreground",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (overdue) {
|
||||||
|
return {
|
||||||
|
label: t("tasks.overdue"),
|
||||||
|
className: "text-red-600 dark:text-red-400",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = showWithoutTime
|
||||||
|
? format(dueDate, "MMM d")
|
||||||
|
: format(dueDate, timeFormat === "12h" ? "MMM d, h:mm a" : "MMM d, HH:mm");
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: formatted,
|
||||||
|
className: "text-muted-foreground",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskListView({
|
||||||
|
tasks,
|
||||||
|
calendars,
|
||||||
|
selectedCalendarIds,
|
||||||
|
filter,
|
||||||
|
showCompleted,
|
||||||
|
onSelectTask,
|
||||||
|
onToggleComplete,
|
||||||
|
selectedTaskId,
|
||||||
|
onQuickCreate,
|
||||||
|
}: TaskListViewProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
const [quickAddTitle, setQuickAddTitle] = useState("");
|
||||||
|
|
||||||
|
const filteredTasks = useMemo(() => {
|
||||||
|
let result = tasks.filter(task => {
|
||||||
|
const calIds = Object.keys(task.calendarIds);
|
||||||
|
return calIds.some(id => selectedCalendarIds.includes(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!showCompleted) {
|
||||||
|
result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter) {
|
||||||
|
case "pending":
|
||||||
|
result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process");
|
||||||
|
break;
|
||||||
|
case "completed":
|
||||||
|
result = result.filter(task => task.progress === "completed");
|
||||||
|
break;
|
||||||
|
case "overdue":
|
||||||
|
result = result.filter(task => {
|
||||||
|
if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
|
||||||
|
return isPast(parseISO(task.due)) && !isToday(parseISO(task.due));
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: overdue first, then by due date (no due date last), then by priority
|
||||||
|
result.sort((a, b) => {
|
||||||
|
// Completed tasks at the bottom
|
||||||
|
if (a.progress === "completed" && b.progress !== "completed") return 1;
|
||||||
|
if (a.progress !== "completed" && b.progress === "completed") return -1;
|
||||||
|
|
||||||
|
// Tasks with due dates before those without
|
||||||
|
if (a.due && !b.due) return -1;
|
||||||
|
if (!a.due && b.due) return 1;
|
||||||
|
if (a.due && b.due) {
|
||||||
|
const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime();
|
||||||
|
if (dateCompare !== 0) return dateCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Higher priority first (lower number = higher priority, but 0 = no priority goes last)
|
||||||
|
const aPri = a.priority || 10;
|
||||||
|
const bPri = b.priority || 10;
|
||||||
|
return aPri - bPri;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [tasks, selectedCalendarIds, filter, showCompleted]);
|
||||||
|
|
||||||
|
const handleToggle = useCallback((e: React.MouseEvent, task: CalendarTask) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleComplete(task);
|
||||||
|
}, [onToggleComplete]);
|
||||||
|
|
||||||
|
if (filteredTasks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
{onQuickCreate && (
|
||||||
|
<div className="px-4 py-2 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={quickAddTitle}
|
||||||
|
onChange={(e) => setQuickAddTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && quickAddTitle.trim()) {
|
||||||
|
onQuickCreate(quickAddTitle.trim());
|
||||||
|
setQuickAddTitle("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t("tasks.quick_add_placeholder")}
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground py-12">
|
||||||
|
<ListTodo className="h-12 w-12 mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">{t("tasks.no_tasks")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{onQuickCreate && (
|
||||||
|
<div className="px-4 py-2 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={quickAddTitle}
|
||||||
|
onChange={(e) => setQuickAddTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && quickAddTitle.trim()) {
|
||||||
|
onQuickCreate(quickAddTitle.trim());
|
||||||
|
setQuickAddTitle("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t("tasks.quick_add_placeholder")}
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{filteredTasks.map(task => {
|
||||||
|
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||||
|
const isCompleted = task.progress === "completed";
|
||||||
|
const priorityIcon = getTaskPriorityIcon(task.priority);
|
||||||
|
const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => onSelectTask(task)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors",
|
||||||
|
selectedTaskId === task.id && "bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleToggle(e, task)}
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors",
|
||||||
|
isCompleted
|
||||||
|
? "bg-success border-success text-success-foreground"
|
||||||
|
: "border-muted-foreground/40 hover:border-primary"
|
||||||
|
)}
|
||||||
|
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
|
||||||
|
>
|
||||||
|
{isCompleted && <Check className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium truncate",
|
||||||
|
isCompleted && "line-through text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{task.title || t("tasks.no_title")}
|
||||||
|
</span>
|
||||||
|
{priorityIcon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{dueDateInfo && (
|
||||||
|
<span className={cn("text-xs flex items-center gap-1", dueDateInfo.className)}>
|
||||||
|
<CalendarDays className="h-3 w-3" />
|
||||||
|
{dueDateInfo.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{cal && (
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: cal.color || "#3b82f6" }} />
|
||||||
|
{cal.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { X, Trash2, CalendarDays, Bell, Flag } from "lucide-react";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
|
||||||
|
|
||||||
|
interface TaskModalProps {
|
||||||
|
task?: CalendarTask | null;
|
||||||
|
calendars: Calendar[];
|
||||||
|
onSave: (data: Partial<CalendarTask>) => void | Promise<void>;
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PriorityLevel = "none" | "high" | "medium" | "low";
|
||||||
|
type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440";
|
||||||
|
|
||||||
|
function priorityToLevel(p: number): PriorityLevel {
|
||||||
|
if (p >= 1 && p <= 4) return "high";
|
||||||
|
if (p === 5) return "medium";
|
||||||
|
if (p >= 6 && p <= 9) return "low";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelToPriority(l: PriorityLevel): number {
|
||||||
|
switch (l) {
|
||||||
|
case "high": return 1;
|
||||||
|
case "medium": return 5;
|
||||||
|
case "low": return 9;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskModal({
|
||||||
|
task,
|
||||||
|
calendars,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
onClose,
|
||||||
|
isMobile: _isMobile,
|
||||||
|
}: TaskModalProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
const isEdit = !!task;
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn);
|
||||||
|
const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? "";
|
||||||
|
|
||||||
|
const [title, setTitle] = useState(task?.title ?? "");
|
||||||
|
const [description, setDescription] = useState(task?.description ?? "");
|
||||||
|
const [dueDate, setDueDate] = useState(task?.due ? format(parseISO(task.due), "yyyy-MM-dd") : "");
|
||||||
|
const [dueTime, setDueTime] = useState(task?.due && !task.showWithoutTime ? format(parseISO(task.due), "HH:mm") : "");
|
||||||
|
const [showTime, setShowTime] = useState(task?.due ? !task.showWithoutTime : false);
|
||||||
|
const [priority, setPriority] = useState<PriorityLevel>(priorityToLevel(task?.priority ?? 0));
|
||||||
|
const [progress, setProgress] = useState<CalendarTask["progress"]>(task?.progress ?? "needs-action");
|
||||||
|
const [calendarId, setCalendarId] = useState(() => {
|
||||||
|
if (task) {
|
||||||
|
const ids = Object.keys(task.calendarIds);
|
||||||
|
return ids[0] ?? defaultCalendarId;
|
||||||
|
}
|
||||||
|
return defaultCalendarId;
|
||||||
|
});
|
||||||
|
const [alertOption, setAlertOption] = useState<AlertOption>(() => {
|
||||||
|
if (!task?.alerts) return "none";
|
||||||
|
const first = Object.values(task.alerts)[0];
|
||||||
|
if (!first || first.trigger["@type"] !== "OffsetTrigger") return "none";
|
||||||
|
const offset = first.trigger.offset;
|
||||||
|
if (offset === "PT0S") return "at_time";
|
||||||
|
const m = offset.match(/-?PT?(\d+)M$/);
|
||||||
|
if (m) return m[1] as AlertOption;
|
||||||
|
const h = offset.match(/-?PT?(\d+)H$/);
|
||||||
|
if (h) return String(parseInt(h[1]) * 60) as AlertOption;
|
||||||
|
const d = offset.match(/-?P(\d+)D/);
|
||||||
|
if (d) return String(parseInt(d[1]) * 1440) as AlertOption;
|
||||||
|
return "none";
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
titleRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
let due: string | null = null;
|
||||||
|
let showWithoutTime = true;
|
||||||
|
if (dueDate) {
|
||||||
|
if (showTime && dueTime) {
|
||||||
|
due = `${dueDate}T${dueTime}:00`;
|
||||||
|
showWithoutTime = false;
|
||||||
|
} else {
|
||||||
|
due = dueDate;
|
||||||
|
showWithoutTime = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let alerts: Record<string, CalendarEventAlert> | null = null;
|
||||||
|
if (alertOption !== "none") {
|
||||||
|
const offset = alertOption === "at_time" ? "PT0S" : `-PT${alertOption}M`;
|
||||||
|
alerts = {
|
||||||
|
"default-alert": {
|
||||||
|
"@type": "Alert",
|
||||||
|
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
|
||||||
|
action: "display",
|
||||||
|
acknowledged: null,
|
||||||
|
relatedTo: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Partial<CalendarTask> = {
|
||||||
|
"@type": "Task",
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || "",
|
||||||
|
due,
|
||||||
|
showWithoutTime,
|
||||||
|
priority: levelToPriority(priority),
|
||||||
|
progress,
|
||||||
|
calendarIds: { [calendarId]: true },
|
||||||
|
alerts,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && task) {
|
||||||
|
data.id = task.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSave(data);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [title, description, dueDate, dueTime, showTime, priority, progress, calendarId, alertOption, isEdit, task, onSave, onClose]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
}, [onClose, handleSave]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-background" onKeyDown={handleKeyDown}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<h2 className="text-sm font-semibold">
|
||||||
|
{isEdit ? t("tasks.edit") : t("tasks.create")}
|
||||||
|
</h2>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<Input
|
||||||
|
ref={titleRef}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={t("tasks.title_placeholder")}
|
||||||
|
className="text-base font-medium"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t("tasks.description_placeholder")}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Due Date */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<CalendarDays className="h-3.5 w-3.5" />
|
||||||
|
{t("tasks.due_date")}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
{dueDate && (
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showTime}
|
||||||
|
onChange={(e) => setShowTime(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
{t("tasks.include_time")}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{showTime && (
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={dueTime}
|
||||||
|
onChange={(e) => setDueTime(e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Flag className="h-3.5 w-3.5" />
|
||||||
|
{t("tasks.priority")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(e.target.value as PriorityLevel)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||||
|
>
|
||||||
|
<option value="none">{t("tasks.priority_none")}</option>
|
||||||
|
<option value="high">{t("tasks.priority_high")}</option>
|
||||||
|
<option value="medium">{t("tasks.priority_medium")}</option>
|
||||||
|
<option value="low">{t("tasks.priority_low")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("tasks.progress")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={progress}
|
||||||
|
onChange={(e) => setProgress(e.target.value as CalendarTask["progress"])}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||||
|
>
|
||||||
|
<option value="needs-action">{t("tasks.progress_needs_action")}</option>
|
||||||
|
<option value="in-process">{t("tasks.progress_in_process")}</option>
|
||||||
|
<option value="completed">{t("tasks.progress_completed")}</option>
|
||||||
|
<option value="cancelled">{t("tasks.progress_cancelled")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar */}
|
||||||
|
{writableCalendars.length > 1 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("tasks.calendar")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={calendarId}
|
||||||
|
onChange={(e) => setCalendarId(e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||||
|
>
|
||||||
|
{writableCalendars.map((cal) => (
|
||||||
|
<option key={cal.id} value={cal.id}>{cal.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alert */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Bell className="h-3.5 w-3.5" />
|
||||||
|
{t("tasks.alert")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={alertOption}
|
||||||
|
onChange={(e) => setAlertOption(e.target.value as AlertOption)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||||
|
>
|
||||||
|
<option value="none">{t("tasks.alert_none")}</option>
|
||||||
|
<option value="at_time">{t("tasks.alert_at_time")}</option>
|
||||||
|
<option value="5">{t("tasks.alert_5min")}</option>
|
||||||
|
<option value="15">{t("tasks.alert_15min")}</option>
|
||||||
|
<option value="30">{t("tasks.alert_30min")}</option>
|
||||||
|
<option value="60">{t("tasks.alert_1hr")}</option>
|
||||||
|
<option value="1440">{t("tasks.alert_1day")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||||
|
<div>
|
||||||
|
{isEdit && onDelete && task && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => onDelete(task.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
{t("tasks.delete")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
{t("tasks.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={!title.trim() || saving}>
|
||||||
|
{t("tasks.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user