Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b36c2215c0
|
||
|
|
61db95c94d
|
||
|
|
1074749cd0
|
||
|
|
3759d3f6c6
|
||
|
|
1ecc5d7ebd
|
||
|
|
0282bc0f0a
|
||
|
|
bff8257f34
|
@@ -3,7 +3,9 @@ plugins {
|
||||
// START: FlutterFire Configuration
|
||||
id 'com.google.gms.google-services'
|
||||
// END: FlutterFire Configuration
|
||||
id "kotlin-android"
|
||||
// Kotlin is provided by Flutter's built-in Kotlin support (the flutter-gradle-plugin
|
||||
// auto-applies kotlin-android). KGP stays declared in settings.gradle so it remains
|
||||
// on the classpath. See https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin/for-app-developers
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
@@ -42,10 +44,6 @@ android {
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
@@ -75,6 +73,12 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx4G
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -140,6 +140,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
||||
|
||||
Widget _buildMessageView(BuildContext context, SCNMessage message, ChannelPreview? channel, KeyTokenPreview? token, UserPreview? user) {
|
||||
final userAccUserID = context.select<AppAuth, String?>((v) => v.userID);
|
||||
final dateFormat = context.select<AppSettings, AppSettingsDateFormat>((v) => v.dateFormat).dateFormat();
|
||||
|
||||
var cfg = AppSettings();
|
||||
|
||||
@@ -229,7 +230,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
||||
}
|
||||
: null,
|
||||
),
|
||||
UI.metaCard(context: context, icon: FontAwesomeIcons.solidTimer, title: 'Timestamp', values: [message.timestamp]),
|
||||
UI.metaCard(context: context, icon: FontAwesomeIcons.solidTimer, title: 'Timestamp', values: [dateFormat.format(DateTime.parse(message.timestamp).toLocal())]),
|
||||
if (cfg.showExtendedAttributes)
|
||||
UI.metaCard(
|
||||
context: context,
|
||||
@@ -319,7 +320,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
||||
children: [
|
||||
UI.channelChip(context: context, text: _resolveChannelName(channel, message), margin: const EdgeInsets.fromLTRB(0, 0, 4, 0), fontSize: 16),
|
||||
Expanded(child: SizedBox()),
|
||||
Text(dateFormat.format(DateTime.parse(message.timestamp)), style: const TextStyle(fontSize: 14)),
|
||||
Text(dateFormat.format(DateTime.parse(message.timestamp).toLocal()), style: const TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACCidGFcYZJGOE5rMRDoNC1Onx5viMJ2gSyrPKG/YK7lJwAAAJiWNW/cljVv
|
||||
3AAAAAtzc2gtZWQyNTUxOQAAACCidGFcYZJGOE5rMRDoNC1Onx5viMJ2gSyrPKG/YK7lJw
|
||||
AAAECzRf/pqBmG4e0SkaIYTyBkXz/zKNxP4got/q3oKkraV6J0YVxhkkY4TmsxEOg0LU6f
|
||||
Hm+IwnaBLKs8ob9gruUnAAAAEnJvYmlud0BiZmIyMDIzMDAwMwECAw==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKJ0YVxhkkY4TmsxEOg0LU6fHm+IwnaBLKs8ob9gruUn gnecht@bfb
|
||||
@@ -0,0 +1,2 @@
|
||||
[url "ssh://git@git.blackforestbytes.com/BlackForestBytes/goext"]
|
||||
insteadOf = https://git.blackforestbytes.com/BlackForestBytes/goext
|
||||
@@ -0,0 +1 @@
|
||||
git.blackforestbytes.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDQ5uGWtRDqXF8A2FP69pPKayYYSazT+Ls/EjpNxczIpHyhpGUOvTH7GC4BMl8rBW6EVEsjPGGVIFJz3x3M1LmIwllj1+XgKQOSGdY/cRNYJDcLWMdG2eCC7gL2nIQwngMnwSs0WkIaG804X0Suq3iPftU8QRiPZTC2yTMFGbbDqF5Una8LGHzjZwZWT+pZqKh8qxVswjlrMQaS8nlCTDnB3ri4hMtp/Hc+1x/8NtvKZmBrbIgydntIN69tAavQTgAyRJIwAMy8cD8P1WizsJChlDdyI+g4WWDULgH+bA6mqrzo4YWflGpY0L1qgmPCGSSXcbb0UdNnEucpB72RPBcx
|
||||
@@ -8,6 +8,15 @@ RUN apt-get update && \
|
||||
pip install virtualenv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# setup for private deps
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git openssh-client && rm -rf /var/lib/apt/lists/*
|
||||
COPY .secrets/deps-key /root/.ssh/id_ed25519
|
||||
COPY .secrets/deps-key.pub /root/.ssh/id_ed25519.pub
|
||||
RUN chmod 600 /root/.ssh/id_ed25519
|
||||
COPY .secrets/known_hosts /root/.ssh/known_hosts
|
||||
COPY .secrets/gitconfig /root/.gitconfig
|
||||
ENV GOPRIVATE="git.blackforestbytes.com/*"
|
||||
|
||||
COPY . /buildsrc
|
||||
|
||||
RUN cd /buildsrc && cp "scn_send.sh" "../scn_send.sh" && make build
|
||||
|
||||
@@ -119,11 +119,17 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
|
||||
}
|
||||
|
||||
if len(q.Search) != 0 {
|
||||
filter.SearchStringFTS = langext.Ptr(langext.ArrMap(q.Search, func(v string) string { return strings.TrimSpace(v) }))
|
||||
searchTerms := langext.ArrFilter(langext.ArrMap(q.Search, func(v string) string { return strings.TrimSpace(v) }), func(v string) bool { return v != "" })
|
||||
if len(searchTerms) != 0 {
|
||||
filter.SearchStringFTS = langext.Ptr(searchTerms)
|
||||
}
|
||||
}
|
||||
|
||||
if len(q.StringSearch) != 0 {
|
||||
filter.SearchStringPlain = langext.Ptr(langext.ArrMap(q.StringSearch, func(v string) string { return strings.TrimSpace(v) }))
|
||||
searchTerms := langext.ArrFilter(langext.ArrMap(q.StringSearch, func(v string) string { return strings.TrimSpace(v) }), func(v string) bool { return v != "" })
|
||||
if len(searchTerms) != 0 {
|
||||
filter.SearchStringPlain = langext.Ptr(searchTerms)
|
||||
}
|
||||
}
|
||||
|
||||
if len(q.Channels) != 0 {
|
||||
|
||||
@@ -4,13 +4,15 @@ import (
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/dataext"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/mathext"
|
||||
"git.blackforestbytes.com/BlackForestBytes/goext/sq"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MessageFilter struct {
|
||||
@@ -219,7 +221,7 @@ func (f MessageFilter) SQL() (string, string, sq.PP, error) {
|
||||
if f.SearchStringFTS != nil {
|
||||
filter := make([]string, 0)
|
||||
for _, v := range *f.SearchStringFTS {
|
||||
filter = append(filter, fmt.Sprintf("(messages_fts match :%s)", params.Add(v)))
|
||||
filter = append(filter, fmt.Sprintf("(messages_fts match :%s)", params.Add(sanitizeFTSMatchQuery(v))))
|
||||
}
|
||||
sqlClauses = append(sqlClauses, "("+strings.Join(filter, " OR ")+")")
|
||||
}
|
||||
@@ -246,6 +248,43 @@ func (f MessageFilter) SQL() (string, string, sq.PP, error) {
|
||||
return sqlClause, joinClause, params, nil
|
||||
}
|
||||
|
||||
// sanitizeFTSMatchQuery converts an arbitrary user-provided search string into a valid FTS5 MATCH query.
|
||||
// It preserves explicit "quoted phrases" typed by the user and wraps every other whitespace-separated word
|
||||
// in double-quotes, so that FTS5 special characters ( - : * ^ ( ) AND OR NOT NEAR ... ) inside plain search
|
||||
// terms are treated as literal text instead of query operators.
|
||||
func sanitizeFTSMatchQuery(s string) string {
|
||||
out := make([]string, 0)
|
||||
buf := strings.Builder{}
|
||||
inQuote := false
|
||||
|
||||
flush := func() {
|
||||
if buf.Len() > 0 {
|
||||
out = append(out, `"`+strings.ReplaceAll(buf.String(), `"`, `""`)+`"`)
|
||||
buf.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '"':
|
||||
// a double-quote opens or closes an explicit phrase; flush the pending word either way
|
||||
flush()
|
||||
inQuote = !inQuote
|
||||
case !inQuote && unicode.IsSpace(r):
|
||||
flush()
|
||||
default:
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
}
|
||||
flush() // trailing word or an unterminated phrase
|
||||
|
||||
if len(out) == 0 {
|
||||
return `""` // valid FTS5 query that simply matches nothing
|
||||
}
|
||||
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
func (f MessageFilter) Hash() string {
|
||||
bh, err := dataext.StructHash(f, dataext.StructHashOptions{HashAlgo: sha512.New()})
|
||||
if err != nil {
|
||||
|
||||
@@ -830,6 +830,11 @@ func TestListMessagesSearch(t *testing.T) {
|
||||
{"search=the(2)", 17, fmt.Sprintf("/api/v2/messages?search=%s", url.QueryEscape("THE"))},
|
||||
{"search=please", 9, fmt.Sprintf("/api/v2/messages?search=%s", url.QueryEscape("please"))},
|
||||
{"search=11pm", 2, fmt.Sprintf("/api/v2/messages?search=%s", url.QueryEscape("\"11:00pm\""))},
|
||||
|
||||
{"search=hyphen", 0, fmt.Sprintf("/api/v2/messages?search=%s", url.QueryEscape("yt-backup"))},
|
||||
{"search=colon", 0, fmt.Sprintf("/api/v2/messages?search=%s", url.QueryEscape("foo:bar"))},
|
||||
{"search=NOT", 0, fmt.Sprintf("/api/v2/messages?search=%s", url.QueryEscape("NOT"))},
|
||||
{"search=empty", 22, fmt.Sprintf("/api/v2/messages?search=%s", url.QueryEscape(""))},
|
||||
}
|
||||
|
||||
for _, testdata := range filterTests {
|
||||
|
||||
@@ -128,6 +128,29 @@ body
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#tr_links
|
||||
{
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: -1px -1px 0 0;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
#tr_links .edge-btn
|
||||
{
|
||||
position: static;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#tr_link_web
|
||||
{
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#tl_link1
|
||||
{
|
||||
top: 0;
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
<a tabindex="-1" href="https://play.google.com/store/apps/details?id=com.blackforestbytes.simplecloudnotifier" class="button bordered edge-btn" id="tl_link1"><span class="icn-google-play"></span></a>
|
||||
<a tabindex="-1" href="https://apps.apple.com/us/app/simplecloudnotifier/id6455594868" class="button bordered edge-btn" id="tl_link2"><span class="icn-app-store"></span></a>
|
||||
|
||||
<a tabindex="-1" href="/api" class="button bordered edge-btn" id="tr_link">API</a>
|
||||
<div id="tr_links">
|
||||
<a tabindex="-1" href="https://app.simplecloudnotifier.de" class="button bordered edge-btn" id="tr_link_web">Web</a>
|
||||
<a tabindex="-1" href="/api" class="button bordered edge-btn" id="tr_link">API</a>
|
||||
</div>
|
||||
|
||||
<a tabindex="-1" href="/" class="linkcaption"><h1>Simple Cloud Notifier</h1></a>
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
PlayCircleOutline,
|
||||
StopOutline,
|
||||
ArrowLeftOutline,
|
||||
DownOutline,
|
||||
} from '@ant-design/icons-angular/icons';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
@@ -91,6 +92,7 @@ const icons: IconDefinition[] = [
|
||||
PlayCircleOutline,
|
||||
StopOutline,
|
||||
ArrowLeftOutline,
|
||||
DownOutline,
|
||||
];
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
|
||||
@@ -1,54 +1,150 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
|
||||
const USER_ID_KEY = 'scn_user_id';
|
||||
const ADMIN_KEY_KEY = 'scn_admin_key';
|
||||
export interface Account {
|
||||
userId: string;
|
||||
adminKey: string;
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
const ACCOUNTS_KEY = 'scn_accounts';
|
||||
const ACTIVE_KEY = 'scn_active_user_id';
|
||||
|
||||
// Legacy single-account storage keys (migrated on first load).
|
||||
const LEGACY_USER_ID_KEY = 'scn_user_id';
|
||||
const LEGACY_ADMIN_KEY_KEY = 'scn_admin_key';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private userId = signal<string | null>(null);
|
||||
private adminKey = signal<string | null>(null);
|
||||
private _accounts = signal<Account[]>([]);
|
||||
private _activeUserId = signal<string | null>(null);
|
||||
|
||||
isAuthenticated = computed(() => !!this.userId() && !!this.adminKey());
|
||||
/** All logged-in accounts. */
|
||||
accounts = this._accounts.asReadonly();
|
||||
activeUserId = this._activeUserId.asReadonly();
|
||||
|
||||
activeAccount = computed(() => {
|
||||
const id = this._activeUserId();
|
||||
return this._accounts().find(a => a.userId === id) ?? null;
|
||||
});
|
||||
|
||||
isAuthenticated = computed(() => !!this.activeAccount());
|
||||
|
||||
constructor() {
|
||||
this.loadFromStorage();
|
||||
}
|
||||
|
||||
private loadFromStorage(): void {
|
||||
const userId = localStorage.getItem(USER_ID_KEY);
|
||||
const adminKey = localStorage.getItem(ADMIN_KEY_KEY);
|
||||
if (userId && adminKey) {
|
||||
this.userId.set(userId);
|
||||
this.adminKey.set(adminKey);
|
||||
const raw = localStorage.getItem(ACCOUNTS_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
this._accounts.set(
|
||||
parsed
|
||||
.filter(a => a && a.userId && a.adminKey)
|
||||
.map(a => ({ userId: a.userId, adminKey: a.adminKey, username: a.username ?? null }))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed storage; treat as logged out.
|
||||
}
|
||||
}
|
||||
|
||||
let active = localStorage.getItem(ACTIVE_KEY);
|
||||
|
||||
// Migrate legacy single-account storage into the multi-account format.
|
||||
if (this._accounts().length === 0) {
|
||||
const legacyId = localStorage.getItem(LEGACY_USER_ID_KEY);
|
||||
const legacyKey = localStorage.getItem(LEGACY_ADMIN_KEY_KEY);
|
||||
if (legacyId && legacyKey) {
|
||||
this._accounts.set([{ userId: legacyId, adminKey: legacyKey, username: null }]);
|
||||
active = legacyId;
|
||||
localStorage.removeItem(LEGACY_USER_ID_KEY);
|
||||
localStorage.removeItem(LEGACY_ADMIN_KEY_KEY);
|
||||
this.persist(active);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the active pointer references an existing account.
|
||||
if (!active || !this._accounts().some(a => a.userId === active)) {
|
||||
active = this._accounts()[0]?.userId ?? null;
|
||||
}
|
||||
this._activeUserId.set(active);
|
||||
}
|
||||
|
||||
private persist(activeOverride?: string | null): void {
|
||||
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(this._accounts()));
|
||||
const active = activeOverride !== undefined ? activeOverride : this._activeUserId();
|
||||
if (active) {
|
||||
localStorage.setItem(ACTIVE_KEY, active);
|
||||
} else {
|
||||
localStorage.removeItem(ACTIVE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** Add (or update) an account and make it the active one. */
|
||||
login(userId: string, adminKey: string): void {
|
||||
localStorage.setItem(USER_ID_KEY, userId);
|
||||
localStorage.setItem(ADMIN_KEY_KEY, adminKey);
|
||||
this.userId.set(userId);
|
||||
this.adminKey.set(adminKey);
|
||||
if (this._accounts().some(a => a.userId === userId)) {
|
||||
this._accounts.update(list =>
|
||||
list.map(a => (a.userId === userId ? { ...a, adminKey } : a))
|
||||
);
|
||||
} else {
|
||||
this._accounts.update(list => [...list, { userId, adminKey, username: null }]);
|
||||
}
|
||||
this._activeUserId.set(userId);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Log out the currently active account. */
|
||||
logout(): void {
|
||||
localStorage.removeItem(USER_ID_KEY);
|
||||
localStorage.removeItem(ADMIN_KEY_KEY);
|
||||
this.userId.set(null);
|
||||
this.adminKey.set(null);
|
||||
const active = this._activeUserId();
|
||||
if (active) {
|
||||
this.removeAccount(active);
|
||||
}
|
||||
}
|
||||
|
||||
/** Log out a specific account (active or background). */
|
||||
logoutAccount(userId: string): void {
|
||||
this.removeAccount(userId);
|
||||
}
|
||||
|
||||
private removeAccount(userId: string): void {
|
||||
this._accounts.update(list => list.filter(a => a.userId !== userId));
|
||||
if (this._activeUserId() === userId) {
|
||||
this._activeUserId.set(this._accounts()[0]?.userId ?? null);
|
||||
}
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Switch which logged-in account is active. */
|
||||
switchAccount(userId: string): void {
|
||||
if (!this._accounts().some(a => a.userId === userId)) return;
|
||||
this._activeUserId.set(userId);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Cache the resolved username on the active account. */
|
||||
setActiveUsername(username: string | null): void {
|
||||
const active = this._activeUserId();
|
||||
if (!active) return;
|
||||
this._accounts.update(list =>
|
||||
list.map(a => (a.userId === active ? { ...a, username } : a))
|
||||
);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
getUserId(): string | null {
|
||||
return this.userId();
|
||||
return this._activeUserId();
|
||||
}
|
||||
|
||||
getAdminKey(): string | null {
|
||||
return this.adminKey();
|
||||
return this.activeAccount()?.adminKey ?? null;
|
||||
}
|
||||
|
||||
getAuthHeader(): string | null {
|
||||
const key = this.adminKey();
|
||||
const key = this.getAdminKey();
|
||||
return key ? `SCN ${key}` : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,12 @@ export class AccountInfoComponent implements OnInit {
|
||||
next: () => {
|
||||
this.notification.success('Account deleted');
|
||||
this.authService.logout();
|
||||
this.router.navigate(['/login']);
|
||||
if (this.authService.isAuthenticated()) {
|
||||
// Another account is still logged in — reload into it cleanly.
|
||||
window.location.assign('/');
|
||||
} else {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.deleting.set(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NzInputModule } from 'ng-zorro-antd/input';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
@@ -27,7 +27,6 @@ import { isAdminKey } from '../../../core/models';
|
||||
export class LoginComponent {
|
||||
private authService = inject(AuthService);
|
||||
private apiService = inject(ApiService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
userId = '';
|
||||
@@ -56,9 +55,11 @@ export class LoginComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
// Login successful
|
||||
// Login successful. Use a full navigation so all per-account state
|
||||
// (singleton caches, component signals) is initialised for the new
|
||||
// active account — important when adding a second account.
|
||||
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/messages';
|
||||
this.router.navigateByUrl(returnUrl);
|
||||
window.location.assign(returnUrl);
|
||||
},
|
||||
error: (err) => {
|
||||
this.authService.logout();
|
||||
|
||||
@@ -56,24 +56,25 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="expert-mode-toggle">
|
||||
<nz-switch
|
||||
[ngModel]="expertMode()"
|
||||
(ngModelChange)="settingsService.setExpertMode($event)"
|
||||
nzSize="small"
|
||||
></nz-switch>
|
||||
<span class="expert-mode-label">Expert</span>
|
||||
<div
|
||||
class="account-trigger"
|
||||
nz-dropdown
|
||||
nzTrigger="click"
|
||||
[nzClickHide]="false"
|
||||
nzPlacement="bottomRight"
|
||||
[nzDropdownMenu]="accountMenu"
|
||||
>
|
||||
<div class="user-info">
|
||||
@if (username()) {
|
||||
<span class="username">{{ username() }}</span>
|
||||
}
|
||||
<span class="user-id mono">{{ userId }}</span>
|
||||
@if (currentKey()) {
|
||||
<span class="key-id mono">{{ currentKey()!.keytoken_id }}</span>
|
||||
}
|
||||
</div>
|
||||
<span nz-icon nzType="down" class="account-caret"></span>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span class="user-id mono">{{ userId }}</span>
|
||||
@if (currentKey()) {
|
||||
<span class="key-id mono">{{ currentKey()!.keytoken_id }}</span>
|
||||
}
|
||||
</div>
|
||||
<button nz-button nzType="text" nzDanger (click)="logout()">
|
||||
<span nz-icon nzType="logout"></span>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</nz-header>
|
||||
<nz-content class="content-area">
|
||||
@@ -81,3 +82,65 @@
|
||||
</nz-content>
|
||||
</nz-layout>
|
||||
</nz-layout>
|
||||
|
||||
<nz-dropdown-menu #accountMenu="nzDropdownMenu">
|
||||
<div class="account-dropdown">
|
||||
<!-- Settings -->
|
||||
<div class="dropdown-section">
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">
|
||||
<span nz-icon nzType="setting"></span>
|
||||
Expert mode
|
||||
</span>
|
||||
<nz-switch
|
||||
[ngModel]="expertMode()"
|
||||
(ngModelChange)="settingsService.setExpertMode($event)"
|
||||
nzSize="small"
|
||||
></nz-switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<!-- Accounts -->
|
||||
<div class="dropdown-section accounts-section">
|
||||
@for (acc of accounts(); track acc.userId) {
|
||||
<div
|
||||
class="account-item"
|
||||
[class.active]="acc.userId === userId"
|
||||
(click)="switchAccount(acc.userId)"
|
||||
>
|
||||
<span nz-icon nzType="user" class="account-avatar"></span>
|
||||
<div class="account-meta">
|
||||
<span class="account-name">{{ acc.username || acc.userId }}</span>
|
||||
<span class="account-sub mono">{{ acc.userId }}</span>
|
||||
</div>
|
||||
@if (acc.userId === userId) {
|
||||
<span nz-icon nzType="check" class="account-check"></span>
|
||||
}
|
||||
<button
|
||||
nz-button
|
||||
nzType="text"
|
||||
nzSize="small"
|
||||
nzDanger
|
||||
class="account-logout"
|
||||
title="Logout"
|
||||
(click)="logoutAccount(acc.userId, $event)"
|
||||
>
|
||||
<span nz-icon nzType="logout"></span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<!-- Add account -->
|
||||
<div class="dropdown-section">
|
||||
<button nz-button nzType="text" class="add-account-btn" (click)="addAccount()">
|
||||
<span nz-icon nzType="user-add"></span>
|
||||
Login with another account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nz-dropdown-menu>
|
||||
|
||||
@@ -51,17 +51,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expert-mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.expert-mode-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.header-trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
@@ -79,11 +68,49 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// --- Account switcher trigger (in header) ---
|
||||
.account-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
max-width: 260px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.account-caret {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
line-height: 1.3;
|
||||
min-width: 0;
|
||||
|
||||
.username,
|
||||
.user-id,
|
||||
.key-id {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
color: #666;
|
||||
@@ -96,6 +123,120 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Account switcher dropdown panel ---
|
||||
.account-dropdown {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.14);
|
||||
padding: 6px;
|
||||
min-width: 260px;
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.dropdown-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: #f0f0f0;
|
||||
margin: 6px 4px;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px 10px;
|
||||
|
||||
.settings-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.accounts-section {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.account-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #e6f4ff;
|
||||
|
||||
&:hover {
|
||||
background: #d6ecff;
|
||||
}
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
font-size: 18px;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.account-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
|
||||
.account-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-sub {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.account-check {
|
||||
color: #1890ff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.account-logout {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.add-account-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
margin-left: 240px;
|
||||
transition: margin-left 0.2s;
|
||||
@@ -116,3 +257,25 @@ nz-layout:has(.ant-layout-sider-collapsed) .content-area {
|
||||
margin-left: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mobile ---
|
||||
@media (max-width: 600px) {
|
||||
.app-header {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-trigger {
|
||||
max-width: 62vw;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
|
||||
// Keep the trigger to one identifying line on small screens.
|
||||
.user-info .key-id {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,11 +39,14 @@ export class MainLayoutComponent implements OnInit {
|
||||
|
||||
isCollapsed = signal(false);
|
||||
userId = this.authService.getUserId();
|
||||
accounts = this.authService.accounts;
|
||||
currentKey = signal<KeyToken | null>(null);
|
||||
username = signal<string | null>(null);
|
||||
expertMode = this.settingsService.expertMode;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadCurrentKey();
|
||||
this.loadUser();
|
||||
}
|
||||
|
||||
private loadCurrentKey(): void {
|
||||
@@ -55,12 +58,44 @@ export class MainLayoutComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private loadUser(): void {
|
||||
const userId = this.userId;
|
||||
if (!userId) return;
|
||||
|
||||
this.apiService.getUser(userId).subscribe({
|
||||
next: (user) => {
|
||||
this.username.set(user.username);
|
||||
// Cache the resolved name so the account switcher can label it.
|
||||
this.authService.setActiveUsername(user.username);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleCollapsed(): void {
|
||||
this.isCollapsed.update(v => !v);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.authService.logout();
|
||||
switchAccount(userId: string): void {
|
||||
if (userId === this.userId) return;
|
||||
this.authService.switchAccount(userId);
|
||||
// Full reload so all per-account state (caches, component signals) resets cleanly.
|
||||
window.location.assign('/');
|
||||
}
|
||||
|
||||
logoutAccount(userId: string, event: Event): void {
|
||||
event.stopPropagation();
|
||||
const wasActive = userId === this.authService.getUserId();
|
||||
this.authService.logoutAccount(userId);
|
||||
|
||||
if (!this.authService.isAuthenticated()) {
|
||||
this.router.navigate(['/login']);
|
||||
} else if (wasActive) {
|
||||
window.location.assign('/');
|
||||
}
|
||||
// Otherwise a background account was removed; the accounts() signal updates the list.
|
||||
}
|
||||
|
||||
addAccount(): void {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user