Compare commits

...
4 Commits
Author SHA1 Message Date
Mikescher b36c2215c0 [AUTO] Adjust gitea workflow for private goext dependency
Build Docker and Deploy / Build Docker Container (push) Successful in 3m33s
Build Docker and Deploy / Run Unit-Tests (push) Successful in 6m43s
Build Docker and Deploy / Deploy to Server (push) Successful in 7s
2026-08-09 13:30:12 +02:00
Mikescher 61db95c94d [Web] Add app link
Build Docker and Deploy / Build Docker Container (push) Successful in 2m7s
Build Docker and Deploy / Run Unit-Tests (push) Successful in 7m41s
Build Docker and Deploy / Deploy to Server (push) Successful in 21s
2026-07-22 14:06:57 +02:00
Mikescher 1074749cd0 [WebApp] AccountSwitcher 2026-07-22 13:56:09 +02:00
Mikescher 3759d3f6c6 [WebApp] Show username 2026-07-22 13:43:42 +02:00
14 changed files with 468 additions and 57 deletions
+7
View File
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACCidGFcYZJGOE5rMRDoNC1Onx5viMJ2gSyrPKG/YK7lJwAAAJiWNW/cljVv
3AAAAAtzc2gtZWQyNTUxOQAAACCidGFcYZJGOE5rMRDoNC1Onx5viMJ2gSyrPKG/YK7lJw
AAAECzRf/pqBmG4e0SkaIYTyBkXz/zKNxP4got/q3oKkraV6J0YVxhkkY4TmsxEOg0LU6f
Hm+IwnaBLKs8ob9gruUnAAAAEnJvYmlud0BiZmIyMDIzMDAwMwECAw==
-----END OPENSSH PRIVATE KEY-----
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKJ0YVxhkkY4TmsxEOg0LU6fHm+IwnaBLKs8ob9gruUn gnecht@bfb
+2
View File
@@ -0,0 +1,2 @@
[url "ssh://git@git.blackforestbytes.com/BlackForestBytes/goext"]
insteadOf = https://git.blackforestbytes.com/BlackForestBytes/goext
+1
View File
@@ -0,0 +1 @@
git.blackforestbytes.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDQ5uGWtRDqXF8A2FP69pPKayYYSazT+Ls/EjpNxczIpHyhpGUOvTH7GC4BMl8rBW6EVEsjPGGVIFJz3x3M1LmIwllj1+XgKQOSGdY/cRNYJDcLWMdG2eCC7gL2nIQwngMnwSs0WkIaG804X0Suq3iPftU8QRiPZTC2yTMFGbbDqF5Una8LGHzjZwZWT+pZqKh8qxVswjlrMQaS8nlCTDnB3ri4hMtp/Hc+1x/8NtvKZmBrbIgydntIN69tAavQTgAyRJIwAMy8cD8P1WizsJChlDdyI+g4WWDULgH+bA6mqrzo4YWflGpY0L1qgmPCGSSXcbb0UdNnEucpB72RPBcx
+9
View File
@@ -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
+23
View File
@@ -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;
+4 -1
View File
@@ -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>
+2
View File
@@ -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 = {
+117 -21
View File
@@ -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']);
}
}