From 99e12d33f33945bf054ee78250088d7a5ee5eeca Mon Sep 17 00:00:00 2001 From: Liosha84 Date: Sat, 7 Nov 2026 20:57:30 +0100 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=85=D0=BE=D0=B4=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20Jambotron=20(OAuth=202.0=20code=20+=20PKCE),=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9,=20=D1=81=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=D0=BD=D1=8B=D0=B5=20=D1=8F=D1=89=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/oauth/start → JAMBOTRON_URL/oauth/authorize (client mail, PKCE S256); /api/oauth/callback: код меняется на сервере у Jambotron /api/oauth/token (Bearer SERVICE_TOKEN) → рабочий ящик сотрудника → сессия, ADMIN Jambotron — ADMIN. - Вход по паролю в веб — только встроенный администратор (когда вход через Jambotron включён). - Пароли приложений (V9 app_passwords): User panel → APP PASSWORDS, только SMTP/IMAP. - Системные ящики (V9 users.is_system): без входа в веб, открывают встроенные администраторы, не в USERS; Admin panel → MAILBOXES → кнопка SYSTEM. Co-Authored-By: Claude Opus 5.5 --- .../com/mailserver/config/JambotronSso.java | 43 +++++ .../com/mailserver/config/MailAdmins.java | 5 + .../com/mailserver/config/SecurityConfig.java | 2 +- .../controller/AppPasswordsController.java | 72 +++++++ .../controller/AuthenticationController.java | 17 +- .../controller/MailboxAdminController.java | 28 ++- .../mailserver/controller/SsoController.java | 176 ++++++++++++++++++ .../main/java/com/mailserver/domain/User.java | 6 + .../com/mailserver/service/AppPasswords.java | 60 ++++++ .../com/mailserver/service/MailboxAccess.java | 12 +- .../com/mailserver/service/UserService.java | 5 + .../src/main/resources/application.properties | 4 + .../V9__sso_app_passwords_system.sql | 15 ++ .../AuthenticationControllerTest.java | 23 ++- .../controller/SsoControllerTest.java | 15 ++ docker-compose.public.yml | 2 + frontend/src/app/app.routes.ts | 1 + frontend/src/app/core/api.service.ts | 10 +- frontend/src/app/core/auth.service.ts | 6 + frontend/src/app/core/models.ts | 10 + frontend/src/app/pages/admin/users.section.ts | 4 +- frontend/src/app/pages/auth/login.page.ts | 31 ++- frontend/src/app/pages/auth/sso.page.ts | 26 +++ .../pages/settings/app-passwords.section.ts | 89 +++++++++ .../app/pages/settings/mailboxes.section.ts | 18 ++ .../src/app/pages/settings/settings.page.ts | 4 +- 26 files changed, 665 insertions(+), 19 deletions(-) create mode 100644 backend/src/main/java/com/mailserver/config/JambotronSso.java create mode 100644 backend/src/main/java/com/mailserver/controller/AppPasswordsController.java create mode 100644 backend/src/main/java/com/mailserver/controller/SsoController.java create mode 100644 backend/src/main/java/com/mailserver/service/AppPasswords.java create mode 100644 backend/src/main/resources/db/migration/V9__sso_app_passwords_system.sql create mode 100644 backend/src/test/java/com/mailserver/controller/SsoControllerTest.java create mode 100644 frontend/src/app/pages/auth/sso.page.ts create mode 100644 frontend/src/app/pages/settings/app-passwords.section.ts diff --git a/backend/src/main/java/com/mailserver/config/JambotronSso.java b/backend/src/main/java/com/mailserver/config/JambotronSso.java new file mode 100644 index 0000000..23e85f1 --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/JambotronSso.java @@ -0,0 +1,43 @@ +package com.mailserver.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * «Sign in with Jambotron»: JAMBOTRON_URL (https://jambotron.com) + SERVICE_TOKEN (the same token Jambotron uses for + * the service API — here it proves to Jambotron that the code is exchanged by this mail server). Both set — people log + * in through Jambotron, web password login only for the built-in administrator. + */ +@Component +public class JambotronSso { + + private final String url; + private final String token; + private final String publicUrl; + + public JambotronSso(@Value("${mailserver.jambotron.url:}") String url, + @Value("${mailserver.service-token:}") String token, + @Value("${mailserver.public-url:}") String publicUrl, + @Value("${mailserver.smtp.hostname:localhost}") String hostname) { + this.url = url.isBlank() ? null : url.trim().replaceAll("/+$", ""); + this.token = token.isBlank() ? null : token.trim(); + this.publicUrl = publicUrl.isBlank() ? "https://" + hostname : publicUrl.trim().replaceAll("/+$", ""); + } + + public boolean enabled() { + return url != null && token != null; + } + + public String url() { + return url; + } + + public String token() { + return token; + } + + /** Where Jambotron sends the browser back: https://mail.jambocheck.com/api/oauth/callback */ + public String callback() { + return publicUrl + "/api/oauth/callback"; + } +} diff --git a/backend/src/main/java/com/mailserver/config/MailAdmins.java b/backend/src/main/java/com/mailserver/config/MailAdmins.java index 25ec7b4..079795f 100644 --- a/backend/src/main/java/com/mailserver/config/MailAdmins.java +++ b/backend/src/main/java/com/mailserver/config/MailAdmins.java @@ -40,6 +40,11 @@ public class MailAdmins { return admins.contains(key) || granted.contains(key); } + /** Built-in administrators (lower case) — owners of system mailboxes. */ + public Set builtIn() { + return admins; + } + /** Built-in administrator from the configuration (admin@ by default): the role can not be taken away. */ public boolean isBuiltIn(String email) { return email != null && admins.contains(email.toLowerCase(Locale.ROOT)); diff --git a/backend/src/main/java/com/mailserver/config/SecurityConfig.java b/backend/src/main/java/com/mailserver/config/SecurityConfig.java index 02d55da..1a00bbf 100644 --- a/backend/src/main/java/com/mailserver/config/SecurityConfig.java +++ b/backend/src/main/java/com/mailserver/config/SecurityConfig.java @@ -53,7 +53,7 @@ public class SecurityConfig { .authorizeExchange(exchange -> exchange .pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll() // service API for Jambotron: its own token, checked by ServiceController (not a mailbox login) - .pathMatchers("/api/service/**").permitAll() + .pathMatchers("/api/service/**", "/api/oauth/start", "/api/oauth/callback").permitAll() .pathMatchers("/api/**", "/actuator/**").authenticated() // the web client (static files, SPA routes) .anyExchange().permitAll() diff --git a/backend/src/main/java/com/mailserver/controller/AppPasswordsController.java b/backend/src/main/java/com/mailserver/controller/AppPasswordsController.java new file mode 100644 index 0000000..304564a --- /dev/null +++ b/backend/src/main/java/com/mailserver/controller/AppPasswordsController.java @@ -0,0 +1,72 @@ +package com.mailserver.controller; + +import com.mailserver.service.AppPasswords; +import com.mailserver.service.UserService; +import jakarta.validation.constraints.Size; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** User panel → APP PASSWORDS: a password per mail program (Thunderbird, phone), SMTP / IMAP only. */ +@RestController +@RequestMapping("/api/app-passwords") +public class AppPasswordsController { + + private static final Logger log = LoggerFactory.getLogger(AppPasswordsController.class); + + public record CreateRequest(@Size(max = 100) String name) { + } + + /** @param password shown once */ + public record Created(AppPasswords.AppPassword appPassword, String password) { + } + + private final AppPasswords passwords; + private final UserService users; + + public AppPasswordsController(AppPasswords passwords, UserService users) { + this.passwords = passwords; + this.users = users; + } + + @GetMapping + public Flux list(Mono auth) { + return me(auth).flatMapMany(id -> passwords.list(id)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Mono create(Mono auth, @RequestBody Mono body) { + return me(auth).zipWith(body).flatMap(pair -> { + String name = pair.getT2().name() == null || pair.getT2().name().isBlank() ? "Mail program" : pair.getT2().name().trim(); + return passwords.create(pair.getT1(), name) + .flatMap(password -> passwords.list(pair.getT1()).last() + .doOnNext(p -> log.info("App password '{}' created for user {}", name, pair.getT1())) + .map(p -> new Created(p, password))); + }); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public Mono delete(Mono auth, @PathVariable Long id) { + return me(auth).flatMap(userId -> passwords.delete(userId, id)) + .flatMap(deleted -> deleted ? Mono.empty() + : Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such app password"))); + } + + private Mono me(Mono auth) { + return auth.map(Authentication::getName).flatMap(users::findByEmail).map(u -> u.getId()); + } +} diff --git a/backend/src/main/java/com/mailserver/controller/AuthenticationController.java b/backend/src/main/java/com/mailserver/controller/AuthenticationController.java index 0d69910..8407506 100644 --- a/backend/src/main/java/com/mailserver/controller/AuthenticationController.java +++ b/backend/src/main/java/com/mailserver/controller/AuthenticationController.java @@ -57,10 +57,12 @@ public class AuthenticationController { private final boolean registrationEnabled; private final LoginGuard guard; private final MailAdmins admins; + private final com.mailserver.config.JambotronSso sso; public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains, @Value("${mailserver.registration.enabled:false}") boolean registrationEnabled, - LoginGuard guard, MailAdmins admins) { + LoginGuard guard, MailAdmins admins, com.mailserver.config.JambotronSso sso) { + this.sso = sso; this.guard = guard; this.admins = admins; this.userService = userService; @@ -73,7 +75,8 @@ public class AuthenticationController { /** Public: is registration open, and in which domains mailboxes can be created. */ @GetMapping("/config") public Map config() { - return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList()); + return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList(), + "ssoEnabled", sso.enabled()); } @PostMapping("/login") @@ -106,6 +109,16 @@ public class AuthenticationController { if (!user.isActive()) { return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated"))); } + // With «Sign in with Jambotron» the web password is only for the built-in administrator + // (Jambotron down); the others' passwords are for mail programs (SMTP / IMAP) + if (sso.enabled() && !admins.isBuiltIn(user.getEmail())) { + return Mono.just(ResponseEntity.badRequest().body(Map.of("error", + "Sign in with Jambotron — the password is only for mail programs"))); + } + if (user.isSystem()) { + return Mono.just(ResponseEntity.badRequest().body(Map.of("error", + "A system mailbox has no web login: its owners open it from their mailbox"))); + } return Mono.just(ResponseEntity.ok(token(user))); }); }); diff --git a/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java index 2bb5df4..d58c5a3 100644 --- a/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java +++ b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java @@ -48,7 +48,10 @@ public class MailboxAdminController { /** @param role ADMIN or USER; @param builtIn the administrator from the configuration — the role stays */ public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap, boolean shared, List members, long usedBytes, long quotaBytes, Instant createdAt, - String role, boolean builtIn) { + String role, boolean builtIn, boolean system) { + } + + public record SystemRequest(boolean system) { } public record RoleRequest(String role) { @@ -190,7 +193,7 @@ public class MailboxAdminController { if (!toAdmin && admins.isBuiltIn(user.getEmail())) { return Mono.error(bad("The built-in administrator stays ADMIN")); } - if (toAdmin && user.isShared()) { + if (toAdmin && (user.isShared() || user.isSystem())) { return Mono.error(bad("A shared mailbox has no login of its own — it can not be ADMIN")); } return users.setAdmin(user, toAdmin).doOnNext(u -> { @@ -200,6 +203,25 @@ public class MailboxAdminController { }))).flatMap(this::view); } + /** + * System mailbox (no-reply@, monitor@, staging@): no web login, not in USERS; the built-in administrators + * open it from their mailbox; programs keep its password. Off — an ordinary mailbox again. + */ + @PutMapping("/{id}/system") + public Mono system(Mono auth, @PathVariable Long id, @RequestBody Mono body) { + return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> { + if (request.system() && (user.isShared() || admins.isAdmin(user.getEmail()))) { + return Mono.error(bad("A shared mailbox or an ADMIN can not be a system mailbox")); + } + log.info("Mailbox {} system {} by administrator {}", user.getEmail(), request.system() ? "ON" : "OFF", me); + return users.setSystem(user, request.system()) + .flatMap(u -> !request.system() ? Mono.just(u) + : Flux.fromIterable(admins.builtIn()).concatMap(users::findOptional) + .concatMap(owner -> access.grant(u.getId(), owner.getId(), MailboxAccess.MEMBER)) + .then(Mono.just(u))); + }))).flatMap(this::view); + } + /** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */ @PutMapping("/{id}/trap") public Mono trap(Mono auth, @PathVariable Long id, @RequestBody Mono body) { @@ -266,7 +288,7 @@ public class MailboxAdminController { return mail.usedBytes(user.getId()).defaultIfEmpty(0L).zipWith(access.members(user.getId()).collectList()) .map(pair -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(), admins.isAdmin(user.getEmail()), user.isTrap(), user.isShared(), pair.getT2(), pair.getT1(), user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), - user.getCreatedAt(), admins.isAdmin(user.getEmail()) ? "ADMIN" : "USER", admins.isBuiltIn(user.getEmail()))); + user.getCreatedAt(), admins.isAdmin(user.getEmail()) ? "ADMIN" : "USER", admins.isBuiltIn(user.getEmail()), user.isSystem())); } private static ResponseStatusException bad(String message) { diff --git a/backend/src/main/java/com/mailserver/controller/SsoController.java b/backend/src/main/java/com/mailserver/controller/SsoController.java new file mode 100644 index 0000000..3e09311 --- /dev/null +++ b/backend/src/main/java/com/mailserver/controller/SsoController.java @@ -0,0 +1,176 @@ +package com.mailserver.controller; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.mailserver.config.JambotronSso; +import com.mailserver.config.MailAdmins; +import com.mailserver.domain.User; +import com.mailserver.service.UserService; +import com.mailserver.util.JwtUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * «Sign in with Jambotron» — OAuth 2.0 authorization code + PKCE (S256): + *
    + *
  1. {@code /api/oauth/start} → browser to {@code JAMBOTRON_URL/oauth/authorize?client_id=mail&…}
  2. + *
  3. Jambotron logs the person in (Google / GitHub / password), only staff get a one-time code
  4. + *
  5. {@code /api/oauth/callback?code&state} → code exchanged server to server at {@code /api/oauth/token} + * (Bearer SERVICE_TOKEN + code_verifier) → who it is, roles, their work mailbox
  6. + *
  7. session of that mailbox → {@code /sso#token=…}; ADMIN of Jambotron gets role ADMIN here
  8. + *
+ */ +@RestController +@RequestMapping("/api/oauth") +public class SsoController { + + private static final Logger log = LoggerFactory.getLogger(SsoController.class); + private static final Duration STATE_TTL = Duration.ofMinutes(10); + private static final SecureRandom RANDOM = new SecureRandom(); + + /** What Jambotron says about the person (its /api/oauth/token). */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record Identity(String sub, String username, String email, List roles, String mailbox) { + } + + private record Pending(String verifier, Instant until) { + } + + private final Map pending = new ConcurrentHashMap<>(); + private final JambotronSso sso; + private final UserService users; + private final MailAdmins admins; + private final JwtUtils jwt; + private final WebClient http; + + public SsoController(JambotronSso sso, UserService users, MailAdmins admins, JwtUtils jwt, WebClient.Builder http) { + this.sso = sso; + this.users = users; + this.admins = admins; + this.jwt = jwt; + this.http = http.build(); + } + + @GetMapping("/start") + public ResponseEntity start() { + if (!sso.enabled()) { + return back("/login?sso_error=" + enc("Sign in with Jambotron is not set up on this server")); + } + Instant now = Instant.now(); + pending.values().removeIf(p -> p.until().isBefore(now)); + String state = random(24); + String verifier = random(48); + pending.put(state, new Pending(verifier, now.plus(STATE_TTL))); + return back(sso.url() + "/oauth/authorize?response_type=code&client_id=mail" + + "&redirect_uri=" + enc(sso.callback()) + "&state=" + state + + "&code_challenge=" + challenge(verifier) + "&code_challenge_method=S256"); + } + + @GetMapping("/callback") + public Mono> callback(@RequestParam(required = false) String code, + @RequestParam(required = false) String state, + @RequestParam(required = false) String error) { + Pending p = state == null ? null : pending.remove(state); + if (error != null) { + return Mono.just(fail(error)); + } + if (code == null || p == null || p.until().isBefore(Instant.now())) { + return Mono.just(fail("The sign-in took too long or was not started here — try again")); + } + return http.post().uri(sso.url() + "/api/oauth/token") + .header("Authorization", "Bearer " + sso.token()) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("grant_type", "authorization_code", "client_id", "mail", "code", code, + "code_verifier", p.verifier(), "redirect_uri", sso.callback())) + .retrieve() + .bodyToMono(Identity.class) + .timeout(Duration.ofSeconds(15)) + .flatMap(this::session) + .onErrorResume(WebClientResponseException.class, e -> { + log.warn("Sign in with Jambotron: token exchange refused {} {}", e.getStatusCode(), e.getResponseBodyAsString()); + return Mono.just(fail("Jambotron did not confirm the sign-in (" + e.getStatusCode().value() + ")")); + }) + .onErrorResume(e -> !(e instanceof SsoRefused), e -> { + log.warn("Sign in with Jambotron failed: {}", e.toString()); + return Mono.just(fail("Jambotron is not reachable — try again later")); + }) + .onErrorResume(SsoRefused.class, e -> Mono.just(fail(e.getMessage()))); + } + + private Mono> session(Identity who) { + if (who.mailbox() == null || who.mailbox().isBlank()) { + return Mono.error(new SsoRefused("You have no work mailbox: mail is for Jambotron staff")); + } + boolean admin = who.roles() != null && who.roles().contains("ROLE_ADMIN"); + return users.findOptional(who.mailbox()) + .switchIfEmpty(Mono.error(new SsoRefused("Your mailbox " + who.mailbox() + " is not created yet — it appears within 10 minutes"))) + .flatMap(user -> { + if (!user.isActive() || user.isShared() || user.isSystem()) { + return Mono.error(new SsoRefused("Mailbox " + who.mailbox() + " is switched off")); + } + return admin && !user.isAdmin() ? users.setAdmin(user, true) : Mono.just(user); + }) + .map(user -> { + if (user.isAdmin()) { + admins.grant(user.getEmail(), true); + } + log.info("WEB: {} signed in with Jambotron as {} ({})", user.getEmail(), who.username(), who.email()); + return back("/sso#token=" + enc(jwt.generateToken(user.getEmail())) + "&email=" + enc(user.getEmail())); + }); + } + + private static final class SsoRefused extends RuntimeException { + SsoRefused(String message) { + super(message); + } + } + + private static ResponseEntity fail(String message) { + return back("/login?sso_error=" + enc(message)); + } + + private static ResponseEntity back(String location) { + return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(location)).build(); + } + + private static String enc(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static String random(int bytes) { + byte[] b = new byte[bytes]; + RANDOM.nextBytes(b); + return Base64.getUrlEncoder().withoutPadding().encodeToString(b); + } + + static String challenge(String verifier) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/backend/src/main/java/com/mailserver/domain/User.java b/backend/src/main/java/com/mailserver/domain/User.java index a4fcd3d..5016953 100644 --- a/backend/src/main/java/com/mailserver/domain/User.java +++ b/backend/src/main/java/com/mailserver/domain/User.java @@ -38,6 +38,10 @@ public class User { @Column("is_admin") private boolean isAdmin = false; + /** System mailbox (no-reply@ …): no web login, owners open it, programs use its passwords (V9). */ + @Column("is_system") + private boolean isSystem = false; + /** Shared mailbox (V7): no login of its own, members open it. */ @Column("is_shared") private boolean isShared = false; @@ -70,6 +74,8 @@ public class User { public void setTrap(boolean trap) { isTrap = trap; } public boolean isAdmin() { return isAdmin; } public void setAdmin(boolean admin) { isAdmin = admin; } + public boolean isSystem() { return isSystem; } + public void setSystem(boolean system) { isSystem = system; } public boolean isShared() { return isShared; } public void setShared(boolean shared) { isShared = shared; } diff --git a/backend/src/main/java/com/mailserver/service/AppPasswords.java b/backend/src/main/java/com/mailserver/service/AppPasswords.java new file mode 100644 index 0000000..049d0d3 --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/AppPasswords.java @@ -0,0 +1,60 @@ +package com.mailserver.service; + +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +/** App passwords (V9): one per mail program, SMTP / IMAP only — never the web client. */ +@Service +public class AppPasswords { + + public record AppPassword(Long id, String name, LocalDateTime createdAt, LocalDateTime lastUsedAt) { + } + + private final DatabaseClient db; + private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + + public AppPasswords(DatabaseClient db) { + this.db = db; + } + + public Flux list(Long userId) { + return db.sql("SELECT id, name, created_at, last_used_at FROM app_passwords WHERE user_id = :u ORDER BY id") + .bind("u", userId) + .map(row -> new AppPassword(row.get("id", Long.class), row.get("name", String.class), + row.get("created_at", LocalDateTime.class), row.get("last_used_at", LocalDateTime.class))) + .all(); + } + + /** @return the password itself — shown once */ + public Mono create(Long userId, String name) { + String password = MailDirectory.randomPassword(); + return db.sql("INSERT INTO app_passwords (user_id, name, hash) VALUES (:u, :n, :h)") + .bind("u", userId).bind("n", name).bind("h", encoder.encode(password)) + .then().thenReturn(password); + } + + public Mono delete(Long userId, Long id) { + return db.sql("DELETE FROM app_passwords WHERE id = :id AND user_id = :u").bind("id", id).bind("u", userId) + .fetch().rowsUpdated().map(n -> n > 0); + } + + /** SMTP / IMAP: does the password match one of the app passwords of this login (and note the use). */ + public boolean matches(Long userId, String password) { + Long hit = db.sql("SELECT id, hash FROM app_passwords WHERE user_id = :u").bind("u", userId) + .map(row -> new Object[]{row.get("id", Long.class), row.get("hash", String.class)}) + .all() + .filter(r -> encoder.matches(password, (String) r[1])) + .map(r -> (Long) r[0]) + .next().block(); + if (hit == null) { + return false; + } + db.sql("UPDATE app_passwords SET last_used_at = NOW() WHERE id = :id").bind("id", hit).then().subscribe(); + return true; + } +} diff --git a/backend/src/main/java/com/mailserver/service/MailboxAccess.java b/backend/src/main/java/com/mailserver/service/MailboxAccess.java index d5d4b37..824696a 100644 --- a/backend/src/main/java/com/mailserver/service/MailboxAccess.java +++ b/backend/src/main/java/com/mailserver/service/MailboxAccess.java @@ -30,15 +30,18 @@ public class MailboxAccess { private final UserRepository users; private final UserService userService; - public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService) { + private final AppPasswords appPasswords; + + public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService, AppPasswords appPasswords) { + this.appPasswords = appPasswords; this.db = db; this.users = users; this.userService = userService; } /** - * Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password → the mailbox the - * session works with, or null. A shared mailbox never logs in by itself. + * Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password or one of its app + * passwords (V9) → the mailbox the session works with, or null. A shared mailbox never logs in by itself. */ public User login(String username, String password) { String name = username.trim().toLowerCase(Locale.ROOT); @@ -46,7 +49,8 @@ public class MailboxAccess { String mailbox = star < 0 ? name : name.substring(0, star); String login = star < 0 ? name : name.substring(star + 1); User user = users.findByEmail(login).block(); - if (user == null || !user.isActive() || user.isShared() || !userService.checkPassword(user, password)) { + if (user == null || !user.isActive() || user.isShared() + || !(userService.checkPassword(user, password) || appPasswords.matches(user.getId(), password))) { return null; } if (mailbox.equals(login)) { diff --git a/backend/src/main/java/com/mailserver/service/UserService.java b/backend/src/main/java/com/mailserver/service/UserService.java index d30e4a7..44ae8cf 100644 --- a/backend/src/main/java/com/mailserver/service/UserService.java +++ b/backend/src/main/java/com/mailserver/service/UserService.java @@ -108,6 +108,11 @@ public class UserService { return userRepository.save(user); } + public Mono setSystem(User user, boolean system) { + user.setSystem(system); + return userRepository.save(user); + } + public Mono setTrap(User user, boolean trap) { user.setTrap(trap); return userRepository.save(user); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index e948ee6..23ed7a4 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -34,6 +34,10 @@ mailserver.passwords-file=${MAIL_PASSWORDS_FILE:} # Service API for Jambotron (/api/service/**, staff mailboxes and lists); empty — the API is off. # In the compose files: generated by secrets-init into the secrets volume (file SERVICE_TOKEN). mailserver.service-token=${SERVICE_TOKEN:} +# Sign in with Jambotron (OAuth 2.0 code + PKCE): https://jambotron.com; empty — password login for everyone +mailserver.jambotron.url=${JAMBOTRON_URL:} +# address of this web client for the OAuth callback; empty — https://MAIL_HOSTNAME +mailserver.public-url=${MAIL_PUBLIC_URL:} mailserver.cors.origins=${MAIL_CORS_ORIGINS:} # Attachments diff --git a/backend/src/main/resources/db/migration/V9__sso_app_passwords_system.sql b/backend/src/main/resources/db/migration/V9__sso_app_passwords_system.sql new file mode 100644 index 0000000..4f07746 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__sso_app_passwords_system.sql @@ -0,0 +1,15 @@ +-- Sign in with Jambotron (OAuth 2.0 code + PKCE): people log in through Jambotron; passwords stay for mail programs. +-- App passwords: one per program (Thunderbird, phone, Kuma …), SMTP / IMAP only, shown once, revoked one by one. +CREATE TABLE app_passwords ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + hash VARCHAR(100) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMP +); +CREATE INDEX app_passwords_user ON app_passwords(user_id); + +-- System mailbox (no-reply@, monitor@, staging@): no person behind it, no web login, its owners (built-in +-- administrators) open it from their own mailbox; programs use its password / app passwords. +ALTER TABLE users ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/src/test/java/com/mailserver/controller/AuthenticationControllerTest.java b/backend/src/test/java/com/mailserver/controller/AuthenticationControllerTest.java index 33c7a0f..127a4b8 100644 --- a/backend/src/test/java/com/mailserver/controller/AuthenticationControllerTest.java +++ b/backend/src/test/java/com/mailserver/controller/AuthenticationControllerTest.java @@ -33,8 +33,9 @@ class AuthenticationControllerTest { private final JwtUtils jwt = mock(JwtUtils.class); private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15), Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO)); + private final com.mailserver.config.MailAdmins admins = new com.mailserver.config.MailAdmins("admin@x.test", "", new MailDomains("x.test", "")); private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt, - mock(MailDomains.class), false, guard, mock(com.mailserver.config.MailAdmins.class)); + mock(MailDomains.class), false, guard, admins, new com.mailserver.config.JambotronSso("", "", "", "mail.x.test")); private final User alice = new User(); @@ -56,6 +57,26 @@ class AuthenticationControllerTest { return controller.login(Mono.just(request), http).block(); } + // «Sign in with Jambotron» on: the web password only for the built-in administrator + @Test + void withJambotronSignInOnlyTheBuiltInAdministratorLogsInWithAPassword() { + AuthenticationController sso = new AuthenticationController(users, mock(MailService.class), jwt, + mock(MailDomains.class), false, guard, admins, + new com.mailserver.config.JambotronSso("https://jambotron.test", "tok", "", "mail.x.test")); + User admin = new User(); + admin.setEmail("admin@x.test"); + when(users.findByEmail("admin@x.test")).thenReturn(Mono.just(admin)); + when(users.checkPassword(eq(admin), anyString())).thenReturn(true); + LoginRequest request = new LoginRequest(); + request.setEmail("alice@x.test"); + request.setPassword("secret"); + MockServerHttpRequest http = MockServerHttpRequest.post("/api/login").remoteAddress(new InetSocketAddress("9.9.9.9", 1)).build(); + assertThat(sso.login(Mono.just(request), http).block().getBody()) + .isEqualTo(Map.of("error", "Sign in with Jambotron — the password is only for mail programs")); + request.setEmail("admin@x.test"); + assertThat(sso.login(Mono.just(request), http).block().getStatusCode()).isEqualTo(HttpStatus.OK); + } + @Test void unknownMailboxAndWrongPasswordLookTheSameAndBothCount() { assertThat(login("1.2.3.4", "ghost@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials")); diff --git a/backend/src/test/java/com/mailserver/controller/SsoControllerTest.java b/backend/src/test/java/com/mailserver/controller/SsoControllerTest.java new file mode 100644 index 0000000..b414d9b --- /dev/null +++ b/backend/src/test/java/com/mailserver/controller/SsoControllerTest.java @@ -0,0 +1,15 @@ +package com.mailserver.controller; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SsoControllerTest { + + // RFC 7636 appendix B + @Test + void pkceChallengeIsS256OfTheVerifier() { + assertThat(SsoController.challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")) + .isEqualTo("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + } +} diff --git a/docker-compose.public.yml b/docker-compose.public.yml index 7b693f1..94da4ed 100644 --- a/docker-compose.public.yml +++ b/docker-compose.public.yml @@ -128,6 +128,8 @@ services: environment: DB_HOST: db MAILSERVER_SECRETS_DIR: /run/mail-secrets + # «Sign in with Jambotron» (OAuth code + PKCE, SERVICE_TOKEN as the client secret); empty — password login + JAMBOTRON_URL: ${JAMBOTRON_URL:-https://jambotron.com} MAIL_DOMAINS: ${MAIL_DOMAINS:-jambocheck.com} MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com} MAIL_TLS_CERT: /tls/cert.pem diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 92714e3..ec4c4ad 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -3,6 +3,7 @@ import { authGuard, guestGuard } from './core/auth'; export const routes: Routes = [ { path: 'login', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/login.page').then(m => m.LoginPage) }, + { path: 'sso', loadComponent: () => import('./pages/auth/sso.page').then(m => m.SsoPage) }, { path: 'register', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/register.page').then(m => m.RegisterPage) }, { path: '', diff --git a/frontend/src/app/core/api.service.ts b/frontend/src/app/core/api.service.ts index e0410f5..20f4fe8 100644 --- a/frontend/src/app/core/api.service.ts +++ b/frontend/src/app/core/api.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { Attachment, BlacklistEntry, Contact, Draft, Folder, MailList, Mailbox, MailboxCreated, MailboxEntry, Message, Profile, ServerConfig } from './models'; +import { AppPassword, Attachment, BlacklistEntry, Contact, Draft, Folder, MailList, Mailbox, MailboxCreated, MailboxEntry, Message, Profile, ServerConfig } from './models'; /** The mail server REST API (same origin, /api). */ @Injectable({ providedIn: 'root' }) @@ -55,6 +55,14 @@ export class ApiService { setMailboxActive(id: number, active: boolean): Observable { return this.http.put(`/api/admin/mailboxes/${id}/active`, { active }); } + setMailboxSystem(id: number, system: boolean): Observable { + return this.http.put(`/api/admin/mailboxes/${id}/system`, { system }); + } + appPasswords(): Observable { return this.http.get('/api/app-passwords'); } + createAppPassword(name: string): Observable<{ appPassword: AppPassword; password: string }> { + return this.http.post<{ appPassword: AppPassword; password: string }>('/api/app-passwords', { name }); + } + deleteAppPassword(id: number): Observable { return this.http.delete(`/api/app-passwords/${id}`); } setMailboxRole(id: number, role: 'ADMIN' | 'USER'): Observable { return this.http.put(`/api/admin/mailboxes/${id}/role`, { role }); } diff --git a/frontend/src/app/core/auth.service.ts b/frontend/src/app/core/auth.service.ts index ab54659..56626a4 100644 --- a/frontend/src/app/core/auth.service.ts +++ b/frontend/src/app/core/auth.service.ts @@ -64,6 +64,12 @@ export class AuthService { if (token && email) { this.store({ token, email }); } else { this.clear(); } } + /** Session from «Sign in with Jambotron» (/sso#token=…). */ + signIn(response: TokenResponse): void { + this.clear(); + this.store(response); + } + logout(): void { this.clear(); this.router.navigate(['/login']); diff --git a/frontend/src/app/core/models.ts b/frontend/src/app/core/models.ts index 41566a9..1aa1fec 100644 --- a/frontend/src/app/core/models.ts +++ b/frontend/src/app/core/models.ts @@ -81,6 +81,7 @@ export interface Mailbox { createdAt: string; role: 'ADMIN' | 'USER'; // ADMIN — Admin panel (users, mailboxes, lists) builtIn: boolean; // administrator from the server configuration — stays ADMIN + system: boolean; // no-reply@, monitor@ …: no web login, the built-in administrators open it } /** MailLists.MailList — mail to the address → a copy to every member, into the folder (null — INBOX) */ @@ -102,6 +103,15 @@ export interface MailboxCreated { export interface ServerConfig { registrationEnabled: boolean; domains: string[]; + ssoEnabled?: boolean; // «Sign in with Jambotron»; then the web password is only for the built-in administrator +} + +/** AppPasswords.AppPassword — a password for one mail program (SMTP / IMAP only) */ +export interface AppPassword { + id: number; + name: string; + createdAt: string; + lastUsedAt: string | null; } export interface Draft { diff --git a/frontend/src/app/pages/admin/users.section.ts b/frontend/src/app/pages/admin/users.section.ts index 3b63ee8..629c797 100644 --- a/frontend/src/app/pages/admin/users.section.ts +++ b/frontend/src/app/pages/admin/users.section.ts @@ -6,7 +6,7 @@ import { Mailbox, errorText } from '../../core/models'; import { DialogService } from '../../ui/dialog.service'; import { ToastService } from '../../ui/toast.service'; -/** Admin panel → USERS: everyone with a login of their own (not shared mailboxes) and their role — ADMIN or USER. */ +/** Admin panel → USERS: people with a login of their own (not shared or system mailboxes) and their role — ADMIN or USER. */ @Component({ selector: 'app-users-section', imports: [MatIconModule], @@ -67,7 +67,7 @@ export class UsersSection { private toasts = inject(ToastService); private readonly all = signal([]); - readonly users = computed(() => this.all().filter(m => !m.shared)); + readonly users = computed(() => this.all().filter(m => !m.shared && !m.system)); readonly me = computed(() => this.state.profile()?.email ?? ''); readonly error = signal(''); diff --git a/frontend/src/app/pages/auth/login.page.ts b/frontend/src/app/pages/auth/login.page.ts index 539df4d..f20003a 100644 --- a/frontend/src/app/pages/auth/login.page.ts +++ b/frontend/src/app/pages/auth/login.page.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { Router, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; import { ApiService } from '../../core/api.service'; import { AuthService } from '../../core/auth.service'; @@ -12,8 +12,24 @@ import { AuthFrameComponent } from './auth-frame.component'; imports: [FormsModule, RouterLink, MatIconModule, AuthFrameComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` - -
+ + @if (sso()) { + + login SIGN IN WITH JAMBOTRON + +

The same login as on jambotron.com — Google, GitHub or password. For Jambotron staff.

+ @if (ssoError()) { + + } + + } + @if (!sso() || adminLogin()) { +
login {{ busy() ? 'LOGGING IN…' : 'LOG IN' }} + } @if (registration()) {

No mailbox yet? Create one @@ -57,9 +74,15 @@ export class LoginPage { readonly busy = signal(false); readonly error = signal(''); readonly registration = signal(false); + readonly sso = signal(false); + readonly adminLogin = signal(false); + readonly ssoError = signal(inject(ActivatedRoute).snapshot.queryParamMap.get('sso_error') ?? ''); constructor() { - inject(ApiService).config().subscribe({ next: c => this.registration.set(c.registrationEnabled), error: () => {} }); + inject(ApiService).config().subscribe({ + next: c => { this.registration.set(c.registrationEnabled); this.sso.set(!!c.ssoEnabled); }, + error: () => {} + }); } submit(): void { diff --git a/frontend/src/app/pages/auth/sso.page.ts b/frontend/src/app/pages/auth/sso.page.ts new file mode 100644 index 0000000..6ee5151 --- /dev/null +++ b/frontend/src/app/pages/auth/sso.page.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { AuthService } from '../../core/auth.service'; + +/** «Sign in with Jambotron» lands here: /sso#token=…&email=… → session → mailbox. */ +@Component({ + selector: 'app-sso', + changeDetection: ChangeDetectionStrategy.OnPush, + template: `

Signing in…

` +}) +export class SsoPage { + constructor() { + const auth = inject(AuthService); + const router = inject(Router); + const params = new URLSearchParams(location.hash.replace(/^#/, '')); + const token = params.get('token'); + const email = params.get('email'); + history.replaceState(null, '', '/sso'); // the token does not stay in the address bar / history + if (token && email) { + auth.signIn({ token, email }); + router.navigate(['/']); + } else { + router.navigate(['/login'], { queryParams: { sso_error: 'Sign-in did not finish — try again' } }); + } + } +} diff --git a/frontend/src/app/pages/settings/app-passwords.section.ts b/frontend/src/app/pages/settings/app-passwords.section.ts new file mode 100644 index 0000000..99b003c --- /dev/null +++ b/frontend/src/app/pages/settings/app-passwords.section.ts @@ -0,0 +1,89 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { ApiService } from '../../core/api.service'; +import { AppPassword, errorText } from '../../core/models'; +import { DialogService } from '../../ui/dialog.service'; +import { ToastService } from '../../ui/toast.service'; + +/** User panel → APP PASSWORDS: Thunderbird, phone … — SMTP / IMAP only; the web client is «Sign in with Jambotron». */ +@Component({ + selector: 'app-app-passwords-section', + imports: [DatePipe, FormsModule, MatIconModule], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'block' }, + template: ` +
+

+ key APP PASSWORDS +

+

For a mail program (Thunderbird, phone): login — your mailbox address, + password — an app password. IMAP and SMTP on this server. Not for the web.

+ @if (secret(); as s) { +
+
«{{ s.name }}» — copy it now, it is shown only once:
+
+ {{ s.password }} + + +
+
+ } +
    + @for (p of list(); track p.id) { +
  • + devices + {{ p.name }} + {{ p.lastUsedAt ? 'used ' + (p.lastUsedAt | date:'d MMM HH:mm') : 'not used yet' }} + +
  • + } @empty { +
  • No app passwords yet
  • + } +
+
+
+
+ +
+
+ ` +}) +export class AppPasswordsSection { + private api = inject(ApiService); + private dialogs = inject(DialogService); + private toasts = inject(ToastService); + + readonly list = signal([]); + readonly secret = signal<{ name: string; password: string } | null>(null); + name = ''; + + constructor() { + this.load(); + } + + load(): void { + this.api.appPasswords().subscribe({ next: l => this.list.set(l), error: () => {} }); + } + + create(): void { + this.api.createAppPassword(this.name.trim()).subscribe({ + next: r => { this.secret.set({ name: r.appPassword.name, password: r.password }); this.name = ''; this.load(); }, + error: e => this.toasts.error(errorText(e)) + }); + } + + remove(p: AppPassword): void { + this.dialogs.confirm(`Revoke «${p.name}»?`, 'That program stops getting and sending mail until it gets a new app password.', 'Revoke', true) + .subscribe(ok => { + if (ok === null) return; + this.api.deleteAppPassword(p.id).subscribe({ next: () => this.load(), error: e => this.toasts.error(errorText(e)) }); + }); + } + + copy(text: string): void { + navigator.clipboard?.writeText(text).then(() => this.toasts.ok('Copied'), () => this.toasts.error('Copy it by hand')); + } +} diff --git a/frontend/src/app/pages/settings/mailboxes.section.ts b/frontend/src/app/pages/settings/mailboxes.section.ts index 5a002b1..cc08774 100644 --- a/frontend/src/app/pages/settings/mailboxes.section.ts +++ b/frontend/src/app/pages/settings/mailboxes.section.ts @@ -70,6 +70,8 @@ import { AuthService } from '../../core/auth.service'; [title]="'Members: ' + (m.members.join(', ') || 'none')">SHARED · {{ m.members.length }} } @if (!m.shared && m.members.length) { +{{ m.members.length }} } + @if (m.system) { SYSTEM } @if (m.trap) { TRAP } {{ size(m.usedBytes) }} @@ -83,6 +85,12 @@ import { AuthService } from '../../core/auth.service'; [attr.aria-label]="(m.trap ? 'Trap off for ' : 'Trap on for ') + m.email" [title]="m.trap ? 'Trap ON: sent mail stays in its INBOX. Click to send for real' : 'Trap: keep what it sends in its own INBOX'"> {{ m.trap ? 'move_to_inbox' : 'outbox' }} + @if (!m.shared && !m.admin) { + + } @if (!m.shared) { } @@ -195,6 +203,16 @@ export class MailboxesSection { } /** Trap on: what the mailbox sends is kept in its own INBOX (staging@). Off: it sends for real again. */ + system(m: Mailbox): void { + const on = !m.system; + if (!confirm(on ? `${m.email} becomes a system mailbox: no web login, the built-in administrators open it. Programs keep its password.` + : `${m.email} becomes an ordinary mailbox again.`)) return; + this.api.setMailboxSystem(m.id, on).subscribe({ + next: () => { this.toasts.ok(m.email + (on ? ' is a system mailbox' : ' is ordinary')); this.load(); }, + error: e => this.toasts.error(errorText(e)) + }); + } + trap(m: Mailbox): void { if (m.trap && !confirm('Switch the trap off? ' + m.email + ' will send mail to real people again.')) return; this.api.setMailboxTrap(m.id, !m.trap).subscribe({ diff --git a/frontend/src/app/pages/settings/settings.page.ts b/frontend/src/app/pages/settings/settings.page.ts index 389622e..f6ada9f 100644 --- a/frontend/src/app/pages/settings/settings.page.ts +++ b/frontend/src/app/pages/settings/settings.page.ts @@ -8,11 +8,12 @@ import { errorText, formatBytes } from '../../core/models'; import { THEMES, ThemeService } from '../../ui/theme.service'; import { ToastService } from '../../ui/toast.service'; import { MyMailboxesSection } from './my-mailboxes.section'; +import { AppPasswordsSection } from './app-passwords.section'; /** User panel: profile name, password, storage, look, my mailboxes. Administration — Admin panel (/admin). */ @Component({ selector: 'app-settings', - imports: [DatePipe, FormsModule, MatIconModule, MyMailboxesSection], + imports: [DatePipe, FormsModule, MatIconModule, MyMailboxesSection, AppPasswordsSection], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -112,6 +113,7 @@ import { MyMailboxesSection } from './my-mailboxes.section'; +
`