mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-26 10:50:43 +00:00
Email Auth per Mailbox implementation - full multi user env
This commit is contained in:
parent
599636ec0a
commit
513143a511
17 changed files with 401 additions and 68 deletions
|
|
@ -47,7 +47,7 @@ oauth = None
|
|||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=[],
|
||||
storage_uri="memory://"
|
||||
storage_uri=os.environ.get('REDIS_URL', 'memory://')
|
||||
)
|
||||
|
||||
class QueueLogHandler(logging.Handler):
|
||||
|
|
|
|||
|
|
@ -2063,6 +2063,35 @@ async function emailRepairDns(domain) {
|
|||
}
|
||||
}
|
||||
|
||||
async function emailSetPassword(address, domain) {
|
||||
const password = prompt(`New password for ${address} (min 8 characters):`);
|
||||
if (!password) return;
|
||||
if (password.length < 8) {
|
||||
await dfAlert('Password must be at least 8 characters.', 'Error');
|
||||
return;
|
||||
}
|
||||
const confirmed = prompt('Confirm password:');
|
||||
if (password !== confirmed) {
|
||||
await dfAlert('Passwords do not match.', 'Error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/email/mailbox/set-password', {
|
||||
method: 'POST',
|
||||
headers: buildApiHeaders({'Content-Type': 'application/json'}),
|
||||
body: JSON.stringify({ address, domain, password })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await dfAlert(`Password set for ${address}.`, 'Success');
|
||||
} else {
|
||||
await dfAlert('Error: ' + (data.error || 'Unknown'), 'Failed');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function emailRedeployWorkers() {
|
||||
if (!confirm('Redeploy all inbound and outbound workers? This will push the latest worker code and bindings to Cloudflare.')) return;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@
|
|||
<td>{{ mb.display_name }}</td>
|
||||
<td>{{ domain }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onclick="emailSetPassword('{{ addr }}', '{{ domain }}')">Set Password</button>
|
||||
<button class="btn btn-sm btn-error" onclick="emailDeleteMailbox('{{ addr }}', '{{ domain }}')">{{ t('email.delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ from flask import Blueprint, render_template, request, jsonify, redirect, url_fo
|
|||
from flask_login import login_required, current_user
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from app import config, docker_client
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from app import config, docker_client, limiter
|
||||
from app.core import email_manager
|
||||
from app.core.cloudflare_api import list_account_zones
|
||||
from app.web.config_loader import load_encrypted_config, load_encrypted_config_with_cipher, config_file_path
|
||||
|
|
@ -23,6 +24,20 @@ def _read_worker_template(filename):
|
|||
|
||||
email_bp = Blueprint('email', __name__, url_prefix='/email')
|
||||
|
||||
def _webmail_origin():
|
||||
request_origin = request.headers.get('Origin', '')
|
||||
if not request_origin:
|
||||
return '*'
|
||||
|
||||
# Allow any origin that looks like our mail subdomains
|
||||
if '.dockflare.app' in request_origin or 'localhost' in request_origin or '127.0.0.1' in request_origin:
|
||||
return request_origin
|
||||
|
||||
# Fallback to the first domain or *
|
||||
domains = config.EMAIL_CONFIG.get('domains', {})
|
||||
first_domain = next(iter(domains), '')
|
||||
return f"https://mail.{first_domain}" if first_domain else '*'
|
||||
|
||||
def save_email_config(email_config_data):
|
||||
cfg, fernet = load_encrypted_config_with_cipher()
|
||||
if not cfg or not fernet:
|
||||
|
|
@ -330,35 +345,98 @@ def sso_callback():
|
|||
return "No webmail domain configured.", 500
|
||||
return redirect(f"https://{return_to}/auth/callback?token={token}")
|
||||
|
||||
def _generate_jwt(username):
|
||||
def _generate_jwt(username, mailboxes=None, role='admin', expiry_seconds=None):
|
||||
email_cfg = config.EMAIL_CONFIG
|
||||
if not email_cfg or 'jwt_signing_key' not in email_cfg:
|
||||
return None
|
||||
|
||||
private_key_pem = email_cfg['jwt_signing_key']
|
||||
|
||||
private_key = serialization.load_pem_private_key(
|
||||
private_key_pem.encode('utf-8'),
|
||||
email_cfg['jwt_signing_key'].encode('utf-8'),
|
||||
password=None
|
||||
)
|
||||
|
||||
mailboxes = []
|
||||
for d, d_data in email_cfg.get('domains', {}).items():
|
||||
for m in d_data.get('mailboxes', {}).keys():
|
||||
mailboxes.append(m)
|
||||
|
||||
|
||||
if mailboxes is None:
|
||||
mailboxes = [
|
||||
m for d in email_cfg.get('domains', {}).values()
|
||||
for m in d.get('mailboxes', {}).keys()
|
||||
]
|
||||
|
||||
if expiry_seconds is None:
|
||||
expiry_seconds = config.EMAIL_JWT_EXPIRY_SECONDS
|
||||
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": username,
|
||||
"iss": config.EMAIL_JWT_ISSUER,
|
||||
"aud": config.EMAIL_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + config.EMAIL_JWT_EXPIRY_SECONDS,
|
||||
"exp": now + expiry_seconds,
|
||||
"mailboxes": mailboxes,
|
||||
"role": "admin"
|
||||
"role": role,
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, private_key, algorithm=config.EMAIL_JWT_ALGORITHM)
|
||||
return token
|
||||
|
||||
return jwt.encode(payload, private_key, algorithm=config.EMAIL_JWT_ALGORITHM)
|
||||
|
||||
|
||||
@email_bp.route('/mailbox/set-password', methods=['POST'])
|
||||
@login_required
|
||||
def set_mailbox_password():
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
address = data.get('address', '')
|
||||
domain = data.get('domain', '')
|
||||
password = data.get('password', '')
|
||||
|
||||
if len(password) < 8:
|
||||
return jsonify({'success': False, 'error': 'Password must be at least 8 characters'}), 400
|
||||
|
||||
email_cfg = config.EMAIL_CONFIG
|
||||
if domain not in email_cfg.get('domains', {}) or address not in email_cfg['domains'][domain].get('mailboxes', {}):
|
||||
return jsonify({'success': False, 'error': 'Mailbox not found'}), 404
|
||||
|
||||
email_cfg['domains'][domain]['mailboxes'][address]['password_hash'] = generate_password_hash(password)
|
||||
save_email_config(email_cfg)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@email_bp.route('/auth/login', methods=['POST', 'OPTIONS'])
|
||||
@limiter.limit("5 per 5 minutes")
|
||||
def mailbox_login():
|
||||
origin = _webmail_origin()
|
||||
|
||||
if request.method == 'OPTIONS':
|
||||
response = current_app.make_default_options_response()
|
||||
response.headers['Access-Control-Allow-Origin'] = origin
|
||||
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
||||
response.headers['Access-Control-Allow-Methods'] = 'POST'
|
||||
return response
|
||||
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
email = data.get('email', '').lower().strip()
|
||||
password = data.get('password', '')
|
||||
|
||||
email_cfg = config.EMAIL_CONFIG
|
||||
mailbox_data = None
|
||||
|
||||
for d in email_cfg.get('domains', {}).values():
|
||||
if email in d.get('mailboxes', {}):
|
||||
mailbox_data = d['mailboxes'][email]
|
||||
break
|
||||
|
||||
_dummy = 'pbkdf2:sha256:600000$dummy$' + 'a' * 64
|
||||
stored_hash = mailbox_data.get('password_hash', '') if mailbox_data else _dummy
|
||||
|
||||
if not stored_hash or not check_password_hash(stored_hash, password) or mailbox_data is None:
|
||||
response = jsonify({'success': False, 'error': 'Invalid email or password'})
|
||||
response.headers['Access-Control-Allow-Origin'] = origin
|
||||
return response, 401
|
||||
|
||||
token = _generate_jwt(email, mailboxes=[email], role='user', expiry_seconds=28800)
|
||||
if not token:
|
||||
return jsonify({'success': False, 'error': 'Auth configuration error'}), 500
|
||||
|
||||
response = jsonify({'success': True, 'token': token})
|
||||
response.headers['Access-Control-Allow-Origin'] = origin
|
||||
return response
|
||||
|
||||
def _check_internal_request():
|
||||
# Block any request that carries Cloudflare edge headers (all public internet
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ def gating_logic():
|
|||
return
|
||||
|
||||
if not current_user.is_authenticated:
|
||||
exempt_endpoints = ['static', 'web.ping', 'web.cloudflare_ping_route', 'setup.step_import_env', 'email.internal_mail_config']
|
||||
exempt_endpoints = ['static', 'web.ping', 'web.cloudflare_ping_route', 'setup.step_import_env', 'email.internal_mail_config', 'email.mailbox_login']
|
||||
oauth_endpoints = ['web.login_provider', 'web.auth_callback', 'web.login']
|
||||
if request.endpoint and not request.endpoint.startswith('auth.') and request.endpoint not in exempt_endpoints and request.endpoint not in oauth_endpoints:
|
||||
try:
|
||||
|
|
@ -229,7 +229,7 @@ def add_security_headers_bp(response):
|
|||
"style-src": ["'self'", "'unsafe-inline'", "https://rsms.me", "https://cdn.jsdelivr.net"],
|
||||
"img-src": ["'self'", "data:", "https://img.shields.io"],
|
||||
"font-src": ["'self'", "https://rsms.me"],
|
||||
"connect-src": ["'self'", "https://cdn.jsdelivr.net"],
|
||||
"connect-src": ["'self'", "https://cdn.jsdelivr.net", "https://mail.*"],
|
||||
"frame-src": ["'none'"]
|
||||
}
|
||||
if is_https:
|
||||
|
|
@ -455,7 +455,6 @@ def access_policies_page():
|
|||
policy = access_groups[default_bypass_id]
|
||||
cf_policy_id = policy.get("cf_policy_id")
|
||||
|
||||
# If no Cloudflare policy ID, create it now
|
||||
if not cf_policy_id or cf_policy_id == default_bypass_id:
|
||||
try:
|
||||
cf_policy = reusable_policies.create_reusable_policy(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,45 @@
|
|||
#!/bin/sh
|
||||
MASTER_URL="${DOCKFLARE_MASTER_URL:-}"
|
||||
INTERNAL_URL="${DOCKFLARE_INTERNAL_URL:-http://dockflare:5000}"
|
||||
echo "{\"masterUrl\": \"${MASTER_URL}\"}" > /usr/share/nginx/html/config.json
|
||||
|
||||
cat > /etc/nginx/conf.d/default.conf << EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 25m;
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self';";
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://dockflare-mail-manager:8025/api/;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
}
|
||||
|
||||
location = /email/auth/login {
|
||||
proxy_pass ${INTERNAL_URL}/email/auth/login;
|
||||
proxy_set_header Host \$http_host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location = /config.json {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \\.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|mp4|webm)$ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
exec nginx -g "daemon off;"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ server {
|
|||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 25m;
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self';";
|
||||
add_header Content-Security-Policy "connect-src *; default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; font-src 'self' data: https://r2cdn.perplexity.ai https://rsms.me;" always;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://dockflare-mail-manager:8025/api/;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import apiClient from './client';
|
||||
export const authApi = {
|
||||
checkAuth: () => apiClient.get('/auth/me'),
|
||||
loginWithPassword: async (baseUrl, email, password) => {
|
||||
const url = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
const response = await fetch(`${url}/email/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
//# sourceMappingURL=auth.js.map
|
||||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,UAAU,CAAA;AAEhC,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,SAAS,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,UAAU,CAAC;CAC3C,CAAA"}
|
||||
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,UAAU,CAAA;AAEhC,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,SAAS,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,UAAU,CAAC;IAE1C,iBAAiB,EAAE,KAAK,EAAE,OAAe,EAAE,KAAa,EAAE,QAAgB,EAAE,EAAE;QAC5E,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAA;QAClE,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,GAAG,mBAAmB,EAAE;YACtD,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;YAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC;SAC1C,CAAC,CAAA;QACF,OAAO,QAAQ,CAAC,IAAI,EAAE,CAAA;IACxB,CAAC;CACF,CAAA"}
|
||||
|
|
@ -2,4 +2,13 @@ import apiClient from './client'
|
|||
|
||||
export const authApi = {
|
||||
checkAuth: () => apiClient.get('/auth/me'),
|
||||
}
|
||||
|
||||
loginWithPassword: async (email: string, password: string) => {
|
||||
const response = await fetch('/email/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ const performEmptyTrash = async () => {
|
|||
<div class="flex flex-col gap-2 p-4 pt-0">
|
||||
<TransitionGroup name="list" appear>
|
||||
<MessageListItem
|
||||
v-for="msg in filteredMessages"
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:selected="store.currentMessage?.id === msg.id"
|
||||
|
|
@ -158,7 +158,7 @@ const performEmptyTrash = async () => {
|
|||
@click="selectMessage(msg)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="filteredMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
<div v-if="displayMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
No messages found.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -175,7 +175,7 @@ const performEmptyTrash = async () => {
|
|||
<div class="flex flex-col gap-2 p-4 pt-0">
|
||||
<TransitionGroup name="list" appear>
|
||||
<MessageListItem
|
||||
v-for="msg in unreadMessages"
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:selected="store.currentMessage?.id === msg.id"
|
||||
|
|
@ -183,7 +183,7 @@ const performEmptyTrash = async () => {
|
|||
@click="selectMessage(msg)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="unreadMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
<div v-if="displayMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
No unread messages.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -200,7 +200,7 @@ const performEmptyTrash = async () => {
|
|||
<div class="flex flex-col gap-2 p-4 pt-0">
|
||||
<TransitionGroup name="list" appear>
|
||||
<MessageListItem
|
||||
v-for="msg in starredMessages"
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:selected="store.currentMessage?.id === msg.id"
|
||||
|
|
@ -208,7 +208,7 @@ const performEmptyTrash = async () => {
|
|||
@click="selectMessage(msg)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="starredMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
<div v-if="displayMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
No starred messages.
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -266,7 +266,7 @@ const __VLS_49 = __VLS_48({
|
|||
appear: true,
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_48));
|
||||
__VLS_50.slots.default;
|
||||
for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.filteredMessages))) {
|
||||
for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.displayMessages))) {
|
||||
/** @type {[typeof MessageListItem, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_51 = __VLS_asFunctionalComponent(MessageListItem, new MessageListItem({
|
||||
|
|
@ -294,7 +294,7 @@ for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.filteredMessages))) {
|
|||
var __VLS_53;
|
||||
}
|
||||
var __VLS_50;
|
||||
if (__VLS_ctx.filteredMessages.length === 0) {
|
||||
if (__VLS_ctx.displayMessages.length === 0) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "p-8 text-center text-muted-foreground" },
|
||||
});
|
||||
|
|
@ -371,7 +371,7 @@ const __VLS_80 = __VLS_79({
|
|||
appear: true,
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_79));
|
||||
__VLS_81.slots.default;
|
||||
for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.unreadMessages))) {
|
||||
for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.displayMessages))) {
|
||||
/** @type {[typeof MessageListItem, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_82 = __VLS_asFunctionalComponent(MessageListItem, new MessageListItem({
|
||||
|
|
@ -399,7 +399,7 @@ for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.unreadMessages))) {
|
|||
var __VLS_84;
|
||||
}
|
||||
var __VLS_81;
|
||||
if (__VLS_ctx.unreadMessages.length === 0) {
|
||||
if (__VLS_ctx.displayMessages.length === 0) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "p-8 text-center text-muted-foreground" },
|
||||
});
|
||||
|
|
@ -476,7 +476,7 @@ const __VLS_111 = __VLS_110({
|
|||
appear: true,
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_110));
|
||||
__VLS_112.slots.default;
|
||||
for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.starredMessages))) {
|
||||
for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.displayMessages))) {
|
||||
/** @type {[typeof MessageListItem, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_113 = __VLS_asFunctionalComponent(MessageListItem, new MessageListItem({
|
||||
|
|
@ -504,7 +504,7 @@ for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.starredMessages))) {
|
|||
var __VLS_115;
|
||||
}
|
||||
var __VLS_112;
|
||||
if (__VLS_ctx.starredMessages.length === 0) {
|
||||
if (__VLS_ctx.displayMessages.length === 0) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "p-8 text-center text-muted-foreground" },
|
||||
});
|
||||
|
|
@ -810,9 +810,7 @@ const __VLS_self = (await import('vue')).defineComponent({
|
|||
searchValue: searchValue,
|
||||
showTrashConfirm: showTrashConfirm,
|
||||
folderColor: folderColor,
|
||||
filteredMessages: filteredMessages,
|
||||
unreadMessages: unreadMessages,
|
||||
starredMessages: starredMessages,
|
||||
displayMessages: displayMessages,
|
||||
toggleSort: toggleSort,
|
||||
selectMessage: selectMessage,
|
||||
emptyTrash: emptyTrash,
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,19 +1,27 @@
|
|||
import { ref } from 'vue'
|
||||
import { mailApi } from '../api/mail'
|
||||
import { useMailStore } from '../stores/mail'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export function useMail() {
|
||||
const store = useMailStore()
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const loadMailboxes = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mailApi.getMailboxes()
|
||||
store.mailboxes = res.data
|
||||
if (res.data.length > 0 && !store.currentMailbox) {
|
||||
store.currentMailbox = res.data[0].address
|
||||
const decoded = authStore.decodeToken()
|
||||
if (decoded?.role === 'user') {
|
||||
const addresses: string[] = decoded.mailboxes || []
|
||||
store.mailboxes = addresses.map((addr: string) => ({ address: addr, display_name: addr }))
|
||||
} else {
|
||||
const res = await mailApi.getMailboxes()
|
||||
store.mailboxes = res.data
|
||||
}
|
||||
if (store.mailboxes.length > 0 && !store.currentMailbox) {
|
||||
store.currentMailbox = store.mailboxes[0].address
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import { authApi } from '../api/auth'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { login } = useAuth()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const token = route.query.token as string
|
||||
if (token) {
|
||||
|
|
@ -14,15 +20,36 @@ onMounted(() => {
|
|||
}
|
||||
})
|
||||
|
||||
const redirectToMaster = async () => {
|
||||
let masterUrl = import.meta.env.VITE_MASTER_URL
|
||||
if (!masterUrl) {
|
||||
const getMasterUrl = async (): Promise<string> => {
|
||||
let url = import.meta.env.VITE_MASTER_URL as string
|
||||
if (!url) {
|
||||
try {
|
||||
const cfg = await fetch('/config.json').then(r => r.json())
|
||||
masterUrl = cfg.masterUrl
|
||||
url = cfg.masterUrl
|
||||
} catch {}
|
||||
}
|
||||
if (!masterUrl) masterUrl = window.location.origin.replace('mail.', '')
|
||||
return url || window.location.origin.replace('mail.', '')
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await authApi.loginWithPassword(email.value, password.value)
|
||||
if (data.success && data.token) {
|
||||
login(data.token)
|
||||
} else {
|
||||
error.value = data.error || 'Invalid email or password'
|
||||
}
|
||||
} catch {
|
||||
error.value = 'Connection error. Please try again.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const redirectToMaster = async () => {
|
||||
const masterUrl = await getMasterUrl()
|
||||
window.location.href = `${masterUrl}/email/sso/callback?return_to=${window.location.hostname}`
|
||||
}
|
||||
</script>
|
||||
|
|
@ -32,9 +59,39 @@ const redirectToMaster = async () => {
|
|||
<div class="w-full max-w-sm space-y-6 rounded-lg border p-8 shadow-sm">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Login to Webmail</h1>
|
||||
<p class="text-sm text-muted-foreground">Sign in via DockFlare Master</p>
|
||||
<p class="text-sm text-muted-foreground">Sign in with your email and password</p>
|
||||
</div>
|
||||
<Button class="w-full" @click="redirectToMaster">Login with SSO</Button>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-3">
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
||||
<Button type="submit" class="w-full" :disabled="loading">
|
||||
{{ loading ? 'Signing in…' : 'Sign in' }}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 border-t" />
|
||||
<span class="text-xs text-muted-foreground">or</span>
|
||||
<div class="flex-1 border-t" />
|
||||
</div>
|
||||
|
||||
<Button variant="outline" class="w-full" @click="redirectToMaster">
|
||||
Admin SSO
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,54 @@
|
|||
/// <reference types="../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
|
||||
import { onMounted } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAuth } from '../composables/useAuth';
|
||||
import { authApi } from '../api/auth';
|
||||
import Button from '../components/ui/Button.vue';
|
||||
const route = useRoute();
|
||||
const { login } = useAuth();
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
onMounted(() => {
|
||||
const token = route.query.token;
|
||||
if (token) {
|
||||
login(token);
|
||||
}
|
||||
});
|
||||
const redirectToMaster = async () => {
|
||||
let masterUrl = import.meta.env.VITE_MASTER_URL;
|
||||
if (!masterUrl) {
|
||||
const getMasterUrl = async () => {
|
||||
let url = import.meta.env.VITE_MASTER_URL;
|
||||
if (!url) {
|
||||
try {
|
||||
const cfg = await fetch('/config.json').then(r => r.json());
|
||||
masterUrl = cfg.masterUrl;
|
||||
url = cfg.masterUrl;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
if (!masterUrl)
|
||||
masterUrl = window.location.origin.replace('mail.', '');
|
||||
return url || window.location.origin.replace('mail.', '');
|
||||
};
|
||||
const handleLogin = async () => {
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
const masterUrl = await getMasterUrl();
|
||||
const data = await authApi.loginWithPassword(masterUrl, email.value, password.value);
|
||||
if (data.success && data.token) {
|
||||
login(data.token);
|
||||
}
|
||||
else {
|
||||
error.value = data.error || 'Invalid email or password';
|
||||
}
|
||||
}
|
||||
catch {
|
||||
error.value = 'Connection error. Please try again.';
|
||||
}
|
||||
finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
const redirectToMaster = async () => {
|
||||
const masterUrl = await getMasterUrl();
|
||||
window.location.href = `${masterUrl}/email/sso/callback?return_to=${window.location.hostname}`;
|
||||
};
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
|
|
@ -43,24 +70,77 @@ __VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1
|
|||
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
|
||||
...{ class: "text-sm text-muted-foreground" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.form, __VLS_intrinsicElements.form)({
|
||||
...{ onSubmit: (__VLS_ctx.handleLogin) },
|
||||
...{ class: "space-y-3" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
|
||||
type: "email",
|
||||
placeholder: "you@example.com",
|
||||
required: true,
|
||||
...{ class: "input input-bordered w-full" },
|
||||
});
|
||||
(__VLS_ctx.email);
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
|
||||
type: "password",
|
||||
placeholder: "Password",
|
||||
required: true,
|
||||
...{ class: "input input-bordered w-full" },
|
||||
});
|
||||
(__VLS_ctx.password);
|
||||
if (__VLS_ctx.error) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
|
||||
...{ class: "text-sm text-destructive" },
|
||||
});
|
||||
(__VLS_ctx.error);
|
||||
}
|
||||
/** @type {[typeof Button, typeof Button, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_0 = __VLS_asFunctionalComponent(Button, new Button({
|
||||
...{ 'onClick': {} },
|
||||
type: "submit",
|
||||
...{ class: "w-full" },
|
||||
disabled: (__VLS_ctx.loading),
|
||||
}));
|
||||
const __VLS_1 = __VLS_0({
|
||||
...{ 'onClick': {} },
|
||||
type: "submit",
|
||||
...{ class: "w-full" },
|
||||
disabled: (__VLS_ctx.loading),
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_0));
|
||||
let __VLS_3;
|
||||
let __VLS_4;
|
||||
let __VLS_5;
|
||||
const __VLS_6 = {
|
||||
__VLS_2.slots.default;
|
||||
(__VLS_ctx.loading ? 'Signing in…' : 'Sign in');
|
||||
var __VLS_2;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "flex items-center gap-2" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div)({
|
||||
...{ class: "flex-1 border-t" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
|
||||
...{ class: "text-xs text-muted-foreground" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div)({
|
||||
...{ class: "flex-1 border-t" },
|
||||
});
|
||||
/** @type {[typeof Button, typeof Button, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_3 = __VLS_asFunctionalComponent(Button, new Button({
|
||||
...{ 'onClick': {} },
|
||||
variant: "outline",
|
||||
...{ class: "w-full" },
|
||||
}));
|
||||
const __VLS_4 = __VLS_3({
|
||||
...{ 'onClick': {} },
|
||||
variant: "outline",
|
||||
...{ class: "w-full" },
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_3));
|
||||
let __VLS_6;
|
||||
let __VLS_7;
|
||||
let __VLS_8;
|
||||
const __VLS_9 = {
|
||||
onClick: (__VLS_ctx.redirectToMaster)
|
||||
};
|
||||
__VLS_2.slots.default;
|
||||
var __VLS_2;
|
||||
__VLS_5.slots.default;
|
||||
var __VLS_5;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['h-screen']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['w-screen']} */ ;
|
||||
|
|
@ -83,12 +163,36 @@ var __VLS_2;
|
|||
/** @type {__VLS_StyleScopedClasses['tracking-tight']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['space-y-3']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['input']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['input-bordered']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['input']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['input-bordered']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-destructive']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border-t']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['border-t']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
Button: Button,
|
||||
email: email,
|
||||
password: password,
|
||||
error: error,
|
||||
loading: loading,
|
||||
handleLogin: handleLogin,
|
||||
redirectToMaster: redirectToMaster,
|
||||
};
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue