Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61db95c94d
|
||
|
|
1074749cd0
|
||
|
|
3759d3f6c6
|
||
|
|
1ecc5d7ebd
|
||
|
|
0282bc0f0a
|
||
|
|
bff8257f34
|
@@ -3,7 +3,9 @@ plugins {
|
|||||||
// START: FlutterFire Configuration
|
// START: FlutterFire Configuration
|
||||||
id 'com.google.gms.google-services'
|
id 'com.google.gms.google-services'
|
||||||
// END: FlutterFire Configuration
|
// 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"
|
id "dev.flutter.flutter-gradle-plugin"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,10 +44,6 @@ android {
|
|||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = '17'
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
main.java.srcDirs += 'src/main/kotlin'
|
||||||
}
|
}
|
||||||
@@ -75,6 +73,12 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source '../..'
|
source '../..'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
org.gradle.jvmargs=-Xmx4G
|
org.gradle.jvmargs=-Xmx4G
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=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) {
|
Widget _buildMessageView(BuildContext context, SCNMessage message, ChannelPreview? channel, KeyTokenPreview? token, UserPreview? user) {
|
||||||
final userAccUserID = context.select<AppAuth, String?>((v) => v.userID);
|
final userAccUserID = context.select<AppAuth, String?>((v) => v.userID);
|
||||||
|
final dateFormat = context.select<AppSettings, AppSettingsDateFormat>((v) => v.dateFormat).dateFormat();
|
||||||
|
|
||||||
var cfg = AppSettings();
|
var cfg = AppSettings();
|
||||||
|
|
||||||
@@ -229,7 +230,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
}
|
}
|
||||||
: null,
|
: 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)
|
if (cfg.showExtendedAttributes)
|
||||||
UI.metaCard(
|
UI.metaCard(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -319,7 +320,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
|
|||||||
children: [
|
children: [
|
||||||
UI.channelChip(context: context, text: _resolveChannelName(channel, message), margin: const EdgeInsets.fromLTRB(0, 0, 4, 0), fontSize: 16),
|
UI.channelChip(context: context, text: _resolveChannelName(channel, message), margin: const EdgeInsets.fromLTRB(0, 0, 4, 0), fontSize: 16),
|
||||||
Expanded(child: SizedBox()),
|
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),
|
SizedBox(height: 8),
|
||||||
|
|||||||
@@ -119,11 +119,17 @@ func (h APIHandler) ListMessages(pctx ginext.PreContext) ginext.HTTPResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(q.Search) != 0 {
|
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 {
|
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 {
|
if len(q.Channels) != 0 {
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import (
|
|||||||
"crypto/sha512"
|
"crypto/sha512"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"git.blackforestbytes.com/BlackForestBytes/goext/dataext"
|
"git.blackforestbytes.com/BlackForestBytes/goext/dataext"
|
||||||
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
|
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
|
||||||
"git.blackforestbytes.com/BlackForestBytes/goext/mathext"
|
"git.blackforestbytes.com/BlackForestBytes/goext/mathext"
|
||||||
"git.blackforestbytes.com/BlackForestBytes/goext/sq"
|
"git.blackforestbytes.com/BlackForestBytes/goext/sq"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type MessageFilter struct {
|
type MessageFilter struct {
|
||||||
@@ -219,7 +221,7 @@ func (f MessageFilter) SQL() (string, string, sq.PP, error) {
|
|||||||
if f.SearchStringFTS != nil {
|
if f.SearchStringFTS != nil {
|
||||||
filter := make([]string, 0)
|
filter := make([]string, 0)
|
||||||
for _, v := range *f.SearchStringFTS {
|
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 ")+")")
|
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
|
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 {
|
func (f MessageFilter) Hash() string {
|
||||||
bh, err := dataext.StructHash(f, dataext.StructHashOptions{HashAlgo: sha512.New()})
|
bh, err := dataext.StructHash(f, dataext.StructHashOptions{HashAlgo: sha512.New()})
|
||||||
if err != nil {
|
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=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=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=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 {
|
for _, testdata := range filterTests {
|
||||||
|
|||||||
@@ -128,6 +128,29 @@ body
|
|||||||
text-align: center;
|
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
|
#tl_link1
|
||||||
{
|
{
|
||||||
top: 0;
|
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://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="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>
|
<a tabindex="-1" href="/" class="linkcaption"><h1>Simple Cloud Notifier</h1></a>
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
PlayCircleOutline,
|
PlayCircleOutline,
|
||||||
StopOutline,
|
StopOutline,
|
||||||
ArrowLeftOutline,
|
ArrowLeftOutline,
|
||||||
|
DownOutline,
|
||||||
} from '@ant-design/icons-angular/icons';
|
} from '@ant-design/icons-angular/icons';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
@@ -91,6 +92,7 @@ const icons: IconDefinition[] = [
|
|||||||
PlayCircleOutline,
|
PlayCircleOutline,
|
||||||
StopOutline,
|
StopOutline,
|
||||||
ArrowLeftOutline,
|
ArrowLeftOutline,
|
||||||
|
DownOutline,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
|
|||||||
@@ -1,54 +1,150 @@
|
|||||||
import { Injectable, signal, computed } from '@angular/core';
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
|
|
||||||
const USER_ID_KEY = 'scn_user_id';
|
export interface Account {
|
||||||
const ADMIN_KEY_KEY = 'scn_admin_key';
|
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({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private userId = signal<string | null>(null);
|
private _accounts = signal<Account[]>([]);
|
||||||
private adminKey = signal<string | null>(null);
|
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() {
|
constructor() {
|
||||||
this.loadFromStorage();
|
this.loadFromStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadFromStorage(): void {
|
private loadFromStorage(): void {
|
||||||
const userId = localStorage.getItem(USER_ID_KEY);
|
const raw = localStorage.getItem(ACCOUNTS_KEY);
|
||||||
const adminKey = localStorage.getItem(ADMIN_KEY_KEY);
|
if (raw) {
|
||||||
if (userId && adminKey) {
|
try {
|
||||||
this.userId.set(userId);
|
const parsed = JSON.parse(raw);
|
||||||
this.adminKey.set(adminKey);
|
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 {
|
login(userId: string, adminKey: string): void {
|
||||||
localStorage.setItem(USER_ID_KEY, userId);
|
if (this._accounts().some(a => a.userId === userId)) {
|
||||||
localStorage.setItem(ADMIN_KEY_KEY, adminKey);
|
this._accounts.update(list =>
|
||||||
this.userId.set(userId);
|
list.map(a => (a.userId === userId ? { ...a, adminKey } : a))
|
||||||
this.adminKey.set(adminKey);
|
);
|
||||||
|
} else {
|
||||||
|
this._accounts.update(list => [...list, { userId, adminKey, username: null }]);
|
||||||
|
}
|
||||||
|
this._activeUserId.set(userId);
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Log out the currently active account. */
|
||||||
logout(): void {
|
logout(): void {
|
||||||
localStorage.removeItem(USER_ID_KEY);
|
const active = this._activeUserId();
|
||||||
localStorage.removeItem(ADMIN_KEY_KEY);
|
if (active) {
|
||||||
this.userId.set(null);
|
this.removeAccount(active);
|
||||||
this.adminKey.set(null);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
getUserId(): string | null {
|
||||||
return this.userId();
|
return this._activeUserId();
|
||||||
}
|
}
|
||||||
|
|
||||||
getAdminKey(): string | null {
|
getAdminKey(): string | null {
|
||||||
return this.adminKey();
|
return this.activeAccount()?.adminKey ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAuthHeader(): string | null {
|
getAuthHeader(): string | null {
|
||||||
const key = this.adminKey();
|
const key = this.getAdminKey();
|
||||||
return key ? `SCN ${key}` : null;
|
return key ? `SCN ${key}` : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,12 @@ export class AccountInfoComponent implements OnInit {
|
|||||||
next: () => {
|
next: () => {
|
||||||
this.notification.success('Account deleted');
|
this.notification.success('Account deleted');
|
||||||
this.authService.logout();
|
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: () => {
|
error: () => {
|
||||||
this.deleting.set(false);
|
this.deleting.set(false);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router, ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { NzInputModule } from 'ng-zorro-antd/input';
|
import { NzInputModule } from 'ng-zorro-antd/input';
|
||||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||||
@@ -27,7 +27,6 @@ import { isAdminKey } from '../../../core/models';
|
|||||||
export class LoginComponent {
|
export class LoginComponent {
|
||||||
private authService = inject(AuthService);
|
private authService = inject(AuthService);
|
||||||
private apiService = inject(ApiService);
|
private apiService = inject(ApiService);
|
||||||
private router = inject(Router);
|
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
|
|
||||||
userId = '';
|
userId = '';
|
||||||
@@ -56,9 +55,11 @@ export class LoginComponent {
|
|||||||
return;
|
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';
|
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/messages';
|
||||||
this.router.navigateByUrl(returnUrl);
|
window.location.assign(returnUrl);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.authService.logout();
|
this.authService.logout();
|
||||||
|
|||||||
@@ -56,24 +56,25 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="expert-mode-toggle">
|
<div
|
||||||
<nz-switch
|
class="account-trigger"
|
||||||
[ngModel]="expertMode()"
|
nz-dropdown
|
||||||
(ngModelChange)="settingsService.setExpertMode($event)"
|
nzTrigger="click"
|
||||||
nzSize="small"
|
[nzClickHide]="false"
|
||||||
></nz-switch>
|
nzPlacement="bottomRight"
|
||||||
<span class="expert-mode-label">Expert</span>
|
[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>
|
||||||
<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>
|
</div>
|
||||||
</nz-header>
|
</nz-header>
|
||||||
<nz-content class="content-area">
|
<nz-content class="content-area">
|
||||||
@@ -81,3 +82,65 @@
|
|||||||
</nz-content>
|
</nz-content>
|
||||||
</nz-layout>
|
</nz-layout>
|
||||||
</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;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expert-mode-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.expert-mode-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-trigger {
|
.header-trigger {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -79,11 +68,49 @@
|
|||||||
gap: 16px;
|
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 {
|
.user-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
line-height: 1.3;
|
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 {
|
.user-id {
|
||||||
color: #666;
|
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 {
|
.content-area {
|
||||||
margin-left: 240px;
|
margin-left: 240px;
|
||||||
transition: margin-left 0.2s;
|
transition: margin-left 0.2s;
|
||||||
@@ -116,3 +257,25 @@ nz-layout:has(.ant-layout-sider-collapsed) .content-area {
|
|||||||
margin-left: 80px;
|
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);
|
isCollapsed = signal(false);
|
||||||
userId = this.authService.getUserId();
|
userId = this.authService.getUserId();
|
||||||
|
accounts = this.authService.accounts;
|
||||||
currentKey = signal<KeyToken | null>(null);
|
currentKey = signal<KeyToken | null>(null);
|
||||||
|
username = signal<string | null>(null);
|
||||||
expertMode = this.settingsService.expertMode;
|
expertMode = this.settingsService.expertMode;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadCurrentKey();
|
this.loadCurrentKey();
|
||||||
|
this.loadUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadCurrentKey(): void {
|
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 {
|
toggleCollapsed(): void {
|
||||||
this.isCollapsed.update(v => !v);
|
this.isCollapsed.update(v => !v);
|
||||||
}
|
}
|
||||||
|
|
||||||
logout(): void {
|
switchAccount(userId: string): void {
|
||||||
this.authService.logout();
|
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']);
|
this.router.navigate(['/login']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user