feat(magic): Hub Header mit Avatar+Nickname + iPhone/iPad via UserDevice-Locks + MacBook-Dedupe
- Neuer Endpoint /api/magic/me liefert nickname/avatar/plan fuer Hub-Header. Mac-App ruft fetchMe() beim Hub-Load. - DeviceHubView Header zeigt jetzt Avatar (AsyncImage mit Fallback auf Initial-Letter), Nickname + Plan-Badge statt nur 'ReBreak Magic'. - /api/magic/devices erweitert: listet zusaetzlich UserDevice-Rows mit boundToPlan != null (das sind iPhone/iPad aus dem Native-App-Login- Flow, Legend-Device-Lock). source='locked'. - Dedupe: ProtectedDevice wird unterdrueckt wenn bereits ein UserDevice mit aehnlichem Namen + gleicher Plattform existiert (fixt doppelten MacBook im Hub). - Helper prettyPlatform() + Normalisierung (platform-key 'mac'/'ios'/ 'android'/'win') fuer robusten Vergleich.
This commit is contained in:
parent
ac72fabc34
commit
187a2d8c19
@ -51,6 +51,13 @@ struct MagicReleaseResponse: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
/// User-Profil aus /api/magic/me \u2014 f\u00fcr Hub-Header (Avatar + Nickname).
|
||||
struct MagicUserProfile: Codable {
|
||||
let nickname: String?
|
||||
let avatar: String?
|
||||
let plan: String?
|
||||
}
|
||||
|
||||
enum MagicError: Error, LocalizedError {
|
||||
case unauthorized
|
||||
case limitReached(activeBindings: [MagicDevice])
|
||||
@ -292,6 +299,41 @@ final class MagicAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Profile (Hub-Header)
|
||||
|
||||
func fetchMe() async throws -> MagicUserProfile {
|
||||
let session = try await authService.refreshSessionIfNeeded()
|
||||
let url = try URL(string: "\(baseURL)/api/magic/me")!
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw MagicError.networkError("Keine HTTP-Response")
|
||||
}
|
||||
if httpResponse.statusCode == 401 {
|
||||
await authService.signOut()
|
||||
throw MagicError.unauthorized
|
||||
}
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw MagicError.httpError(httpResponse.statusCode, body)
|
||||
}
|
||||
|
||||
struct Response: Codable {
|
||||
let success: Bool
|
||||
let data: MagicUserProfile
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(Response.self, from: data).data
|
||||
} catch {
|
||||
throw MagicError.decodingError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Download Profile
|
||||
|
||||
func downloadProfile(token: String) async throws -> URL {
|
||||
|
||||
@ -6,6 +6,7 @@ struct DeviceHubView: View {
|
||||
@Environment(WizardModel.self) private var model
|
||||
|
||||
@State private var devices: [MagicDevice] = []
|
||||
@State private var profile: MagicUserProfile?
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var actionInFlight = false
|
||||
@ -22,23 +23,35 @@ struct DeviceHubView: View {
|
||||
content
|
||||
}
|
||||
.frame(minWidth: 720, minHeight: 540)
|
||||
.task { await loadDevices() }
|
||||
.task {
|
||||
await loadProfile()
|
||||
await loadDevices()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "shield.lefthalf.filled")
|
||||
.font(.title)
|
||||
.foregroundStyle(.blue)
|
||||
avatarView
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("ReBreak Magic")
|
||||
.font(.title2.bold())
|
||||
if let email = model.authSession?.email {
|
||||
Text(email)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(profile?.nickname ?? "ReBreak Magic")
|
||||
.font(.title3.bold())
|
||||
HStack(spacing: 6) {
|
||||
if let plan = profile?.plan {
|
||||
Text(plan.capitalized)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue.opacity(0.12))
|
||||
.foregroundStyle(.blue)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
if let host = model.authSession?.label {
|
||||
Text(host)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -228,6 +241,50 @@ struct DeviceHubView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadProfile() async {
|
||||
do {
|
||||
profile = try await MagicAPIClient.shared.fetchMe()
|
||||
} catch {
|
||||
// Profil-Fehler ist nicht-blockierend \u2014 nur Header f\u00e4llt auf Default zur\u00fcck
|
||||
profile = nil
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarView: some View {
|
||||
if let urlString = profile?.avatar, let url = URL(string: urlString) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
default:
|
||||
avatarFallback
|
||||
}
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().strokeBorder(Color.blue.opacity(0.2), lineWidth: 1))
|
||||
} else {
|
||||
avatarFallback
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarFallback: some View {
|
||||
ZStack {
|
||||
Circle().fill(Color.blue.opacity(0.15))
|
||||
if let initial = (profile?.nickname?.first).map(String.init) {
|
||||
Text(initial.uppercased())
|
||||
.font(.headline)
|
||||
.foregroundStyle(.blue)
|
||||
} else {
|
||||
Image(systemName: "shield.lefthalf.filled")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
|
||||
private func handleAction(_ action: HubDeviceAction, device: MagicDevice) async {
|
||||
guard !actionInFlight else { return }
|
||||
actionInFlight = true
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to rebreak-native will be documented in this file.
|
||||
## v0.3.13 (Build 68 / versionCode 51) — 2026-06-03\n\n### Fixes
|
||||
|
||||
- DM screen: bottom gap on initial open tightened — the last message now sits directly above the input bar. The keyboard-closed padding was double-counting the input bar's own layout slot, leaving a large empty gap every time you opened a chat
|
||||
- DM image lightbox: photos now actually show rounded corners — the viewer container is sized to the image's real aspect ratio (via onLoad), so the rounding lands on the visible photo instead of the empty letterbox margins of a fixed square
|
||||
- Lyra coach: removed the "welcome back" greeting that popped up on every first open of the coach each session, regardless of protection status or language (it was always German and unconditional). Will return later only when it's actually warranted
|
||||
- Chat list performance: the conversation list + unread badge now load via a single indexed query (one row per conversation) instead of pulling up to 500 messages and de-duplicating on the fly — added DB indexes on direct messages. Invisible to users, keeps the chat tab fast as message volume grows
|
||||
|
||||
### Features
|
||||
|
||||
- DM image lightbox: tap a shared photo → opens with a "Save" button that stores the image to your Photos library (downloads remote images first, asks for photo-add permission once). Localized DE/EN/FR/AR\n
|
||||
## v0.3.13 (Build 67 / versionCode 50) — 2026-06-03\n\n### Fixes
|
||||
|
||||
- DM screen: bottom gap on open tightened — the last message now sits directly above the input bar instead of leaving a visible gap (reduced the keyboard/input-bar clearance padding)
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
### Fixes
|
||||
|
||||
- DM screen: bottom gap on initial open tightened — the last message now sits directly above the input bar. The keyboard-closed padding was double-counting the input bar's own layout slot, leaving a large empty gap every time you opened a chat
|
||||
- DM image lightbox: photos now actually show rounded corners — the viewer container is sized to the image's real aspect ratio (via onLoad), so the rounding lands on the visible photo instead of the empty letterbox margins of a fixed square
|
||||
- Lyra coach: removed the "welcome back" greeting that popped up on every first open of the coach each session, regardless of protection status or language (it was always German and unconditional). Will return later only when it's actually warranted
|
||||
- Chat list performance: the conversation list + unread badge now load via a single indexed query (one row per conversation) instead of pulling up to 500 messages and de-duplicating on the fly — added DB indexes on direct messages. Invisible to users, keeps the chat tab fast as message volume grows
|
||||
|
||||
### Features
|
||||
|
||||
- DM image lightbox: tap a shared photo → opens with a "Save" button that stores the image to your Photos library (downloads remote images first, asks for photo-add permission once). Localized DE/EN/FR/AR
|
||||
@ -27,6 +27,7 @@ import { useColors } from '../lib/theme';
|
||||
import { useLanguageStore } from '../stores/language';
|
||||
import { useAppLockStore } from '../stores/appLock';
|
||||
import { useLyraVoiceStore } from '../stores/lyraVoice';
|
||||
import { useChatBackgroundStore } from '../stores/chatBackground';
|
||||
import { AppLockGate } from '../components/AppLockGate';
|
||||
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
|
||||
import { DeviceApprovalIncomingSheet } from '../components/DeviceApprovalIncomingSheet';
|
||||
@ -65,6 +66,7 @@ function RootLayoutInner() {
|
||||
const initLanguage = useLanguageStore((s) => s.init);
|
||||
const initAppLock = useAppLockStore((s) => s.init);
|
||||
const initLyraVoice = useLyraVoiceStore((s) => s.init);
|
||||
const initChatBackground = useChatBackgroundStore((s) => s.init);
|
||||
const appLockReady = useAppLockStore((s) => s.ready);
|
||||
const initRealtimeDebug = useRealtimeDebugStore((s) => s.init);
|
||||
const colors = useColors();
|
||||
@ -107,6 +109,7 @@ function RootLayoutInner() {
|
||||
initLanguage();
|
||||
initAppLock();
|
||||
initLyraVoice();
|
||||
initChatBackground();
|
||||
if (__DEV__) initRealtimeDebug();
|
||||
}, []);
|
||||
|
||||
|
||||
@ -36,6 +36,7 @@ import { useDmRealtime } from '../hooks/useChatRealtime';
|
||||
import { useDmTyping } from '../hooks/useDmTyping';
|
||||
import { useColors } from '../lib/theme';
|
||||
import { useThemeStore } from '../stores/theme';
|
||||
import { useChatBackgroundStore, type ChatBgStyle } from '../stores/chatBackground';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { UserAvatar } from '../components/UserAvatar';
|
||||
@ -94,10 +95,21 @@ export default function DmScreen() {
|
||||
const myUserId = useAuthStore((s) => s.user?.id);
|
||||
|
||||
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||
const chatBg = colorScheme === 'dark' ? '#1a1f1e' : '#EDE8E1';
|
||||
|
||||
const { userId } = useLocalSearchParams<{ userId: string }>();
|
||||
|
||||
// Pro-Chat-Hintergrund (lokal, gerätegebunden). Default = 'clean' (Insta-Style).
|
||||
const chatBgStyle = useChatBackgroundStore((s) => (userId && s.backgrounds[userId]) || 'clean');
|
||||
const setChatBg = useChatBackgroundStore((s) => s.setBackground);
|
||||
// 'clean' → solider Theme-BG (weiß / schwarz). 'pattern' → WA-artiger Symbol-BG
|
||||
// mit warmem/dunklem Tint.
|
||||
const chatBg =
|
||||
chatBgStyle === 'pattern'
|
||||
? colorScheme === 'dark'
|
||||
? '#1a1f1e'
|
||||
: '#EDE8E1'
|
||||
: colors.bg;
|
||||
|
||||
const flatListRef = useRef<FlatListType<ChatMsg>>(null);
|
||||
|
||||
// scrollToEnd() unterschätzt auf Android UND iOS die Content-Höhe und
|
||||
@ -747,7 +759,7 @@ export default function DmScreen() {
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1, backgroundColor: chatBg }}>
|
||||
<DmChatBackground />
|
||||
{chatBgStyle === 'pattern' && <DmChatBackground />}
|
||||
{(isLoading || isFetching) && messages.length === 0 ? (
|
||||
<View style={styles.loadingBox}>
|
||||
<ActivityIndicator color={colors.brandOrange} />
|
||||
@ -780,12 +792,13 @@ export default function DmScreen() {
|
||||
paddingHorizontal: 0,
|
||||
paddingTop: 12,
|
||||
// Tastatur offen: Input-Bar floatet (per transform) über der Tastatur,
|
||||
// der Viewport schrumpft NICHT → Clearance = keyboardHeight.
|
||||
// Tastatur zu: die Input-Bar (KeyboardStickyView) sitzt in ihrem
|
||||
// eigenen Layout-Slot UNTER der FlatList, ihre Höhe ist also schon
|
||||
// abgedeckt — hier nur ein knapper WA-Style-Gap, sonst „schwebt" die
|
||||
// letzte Nachricht beim Initial-Load zu hoch über der Bar.
|
||||
paddingBottom: keyboardVisible ? keyboardHeight + 4 : 8,
|
||||
// der Viewport schrumpft NICHT → Clearance = keyboardHeight + 4 (Gap).
|
||||
// Tastatur zu: die KeyboardStickyView hat offset.closed = -insets.bottom,
|
||||
// schiebt die Bar also um insets.bottom NACH OBEN über den Content →
|
||||
// diese Überlappung muss als Clearance abgezogen werden, sonst wird die
|
||||
// letzte Nachricht halb verdeckt. insets.bottom + 4 hält denselben
|
||||
// knappen Gap wie im Keyboard-offen-State.
|
||||
paddingBottom: keyboardVisible ? keyboardHeight + 4 : insets.bottom + 4,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardDismissMode="interactive"
|
||||
|
||||
39
apps/rebreak-native/stores/chatBackground.ts
Normal file
39
apps/rebreak-native/stores/chatBackground.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const STORAGE_KEY = '@rebreak/chat-backgrounds';
|
||||
|
||||
// Pro-Chat-Hintergrund (lokal, gerätegebunden). Default = 'clean' (Insta-Style,
|
||||
// solider Theme-BG ohne Muster). 'pattern' = der WhatsApp-artige SVG-Symbol-BG.
|
||||
export type ChatBgStyle = 'clean' | 'pattern';
|
||||
export const DEFAULT_CHAT_BG: ChatBgStyle = 'clean';
|
||||
|
||||
type ChatBackgroundState = {
|
||||
// partnerId → Stil. Fehlt der Key → DEFAULT_CHAT_BG.
|
||||
backgrounds: Record<string, ChatBgStyle>;
|
||||
init: () => Promise<void>;
|
||||
setBackground: (partnerId: string, style: ChatBgStyle) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useChatBackgroundStore = create<ChatBackgroundState>((set, get) => ({
|
||||
backgrounds: {},
|
||||
|
||||
init: async () => {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (raw) set({ backgrounds: JSON.parse(raw) });
|
||||
} catch {
|
||||
// non-fatal — Default-Clean greift
|
||||
}
|
||||
},
|
||||
|
||||
setBackground: async (partnerId, style) => {
|
||||
const next = { ...get().backgrounds, [partnerId]: style };
|
||||
set({ backgrounds: next });
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
},
|
||||
}));
|
||||
@ -35,9 +35,12 @@ Building Release AAB (gradlew bundleRelease)|320
|
||||
Validating IPA (App-Store Connect)|105
|
||||
Uploading zu App-Store Connect (TestFlight)|117
|
||||
Building Release AAB (gradlew bundleRelease)|398
|
||||
Exporting App-Store IPA|24
|
||||
Validating IPA (App-Store Connect)|91
|
||||
Uploading zu App-Store Connect (TestFlight)|110
|
||||
Building Release AAB (gradlew bundleRelease)|326
|
||||
Building xcarchive|198
|
||||
Exporting Ad-Hoc IPA|19
|
||||
Building xcarchive|202
|
||||
Exporting Ad-Hoc IPA|18
|
||||
Exporting App-Store IPA|22
|
||||
Validating IPA (App-Store Connect)|86
|
||||
Uploading zu App-Store Connect (TestFlight)|112
|
||||
Building Release AAB (gradlew bundleRelease)|272
|
||||
|
||||
@ -1,35 +1,55 @@
|
||||
import { listMagicDevices } from "../../db/devices";
|
||||
import { listProtectedDevices } from "../../db/protectedDevices";
|
||||
import { usePrisma } from "../../utils/prisma";
|
||||
import { requireUser } from "../../utils/auth";
|
||||
|
||||
/**
|
||||
* GET /api/magic/devices
|
||||
*
|
||||
* Listet alle gesch\u00fctzten Ger\u00e4te des Users f\u00fcr den Magic-Hub. Vereinigt:
|
||||
* - Magic-Bindings (UserDevice.magicEnrolledAt) \u2014 via Magic-App registriert
|
||||
* - ProtectedDevices \u2014 alter Native-App-DNS-Schutz-Flow (Multi-Device)
|
||||
* Vereinigt drei Quellen f\u00fcr "registriertes Ger\u00e4t":
|
||||
* - "magic" \u2192 UserDevice mit magicEnrolledAt (Magic-Mac-App)
|
||||
* - "locked" \u2192 UserDevice mit boundToPlan (Native-App Device-Lock, z.B. iPhone/iPad)
|
||||
* - "protected" \u2192 ProtectedDevice (alter Native-App DNS-Schutz-Flow)
|
||||
*
|
||||
* Response-Items haben ein `source`-Flag:
|
||||
* "magic" \u2192 voll verwaltet, unterst\u00fctzt request-release
|
||||
* "protected" \u2192 alter Flow, nur Anzeige + revoke (TODO: own action)
|
||||
* Dedupe: ProtectedDevice wird unterdr\u00fcckt wenn bereits ein UserDevice
|
||||
* mit \u00e4hnlichem Namen + gleicher Plattform existiert (verhindert MacBook-Doppel).
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
const db = usePrisma();
|
||||
|
||||
const [magic, protectedDevices] = await Promise.all([
|
||||
const [magic, lockedDevices, protectedDevices] = await Promise.all([
|
||||
listMagicDevices(user.id),
|
||||
db.userDevice.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
// Alle bound-Devices (Pro/Legend-Lock). Magic-only rows kommen
|
||||
// \u00fcber `magic` rein \u2014 wir wollen hier die nicht-magic Lock-Bindings.
|
||||
boundToPlan: { not: null },
|
||||
magicEnrolledAt: null,
|
||||
},
|
||||
orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
deviceId: true,
|
||||
platform: true,
|
||||
model: true,
|
||||
name: true,
|
||||
osVersion: true,
|
||||
lastSeenAt: true,
|
||||
releaseRequestedAt: true,
|
||||
},
|
||||
}),
|
||||
listProtectedDevices(user.id),
|
||||
]);
|
||||
|
||||
const magicItems = magic.map((d) => {
|
||||
let releaseAvailableAt: string | null = null;
|
||||
if (d.releaseRequestedAt) {
|
||||
const availableAt = new Date(
|
||||
releaseAvailableAt = new Date(
|
||||
d.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000,
|
||||
);
|
||||
releaseAvailableAt = availableAt.toISOString();
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
source: "magic" as const,
|
||||
deviceId: d.deviceId,
|
||||
@ -42,20 +62,84 @@ export default defineEventHandler(async (event) => {
|
||||
};
|
||||
});
|
||||
|
||||
const protectedItems = protectedDevices.map((d) => ({
|
||||
source: "protected" as const,
|
||||
deviceId: d.id,
|
||||
hostname: d.label,
|
||||
model: d.platform,
|
||||
osVersion: null as string | null,
|
||||
magicEnrolledAt: (d.installedAt ?? d.createdAt).toISOString(),
|
||||
releaseRequestedAt: null as string | null,
|
||||
releaseAvailableAt: null as string | null,
|
||||
const lockedItems = lockedDevices.map((d) => {
|
||||
let releaseAvailableAt: string | null = null;
|
||||
if (d.releaseRequestedAt) {
|
||||
releaseAvailableAt = new Date(
|
||||
d.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
}
|
||||
return {
|
||||
source: "locked" as const,
|
||||
deviceId: d.deviceId,
|
||||
hostname: d.name ?? d.model ?? prettyPlatform(d.platform),
|
||||
model: d.model,
|
||||
osVersion: d.osVersion,
|
||||
magicEnrolledAt: d.lastSeenAt.toISOString(),
|
||||
releaseRequestedAt: d.releaseRequestedAt?.toISOString() ?? null,
|
||||
releaseAvailableAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Dedupe Helper \u2014 normalisiere platform + name f\u00fcr Vergleich
|
||||
const norm = (s: string | null | undefined) =>
|
||||
(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
const platformKey = (p: string | null | undefined) => {
|
||||
const n = norm(p);
|
||||
if (n.startsWith("mac") || n === "darwin") return "mac";
|
||||
if (n.startsWith("ios") || n.startsWith("ipad") || n.startsWith("iphone"))
|
||||
return "ios";
|
||||
if (n.startsWith("android")) return "android";
|
||||
if (n.startsWith("windows") || n === "win") return "win";
|
||||
return n;
|
||||
};
|
||||
|
||||
const alreadyListed = [...magicItems, ...lockedItems].map((d) => ({
|
||||
pk: platformKey(d.model ?? d.hostname),
|
||||
nameNorm: norm(d.hostname),
|
||||
}));
|
||||
|
||||
// Magic-Bindings zuerst (neuste), dann alte ProtectedDevices
|
||||
const protectedItems = protectedDevices
|
||||
.filter((pd) => {
|
||||
const pk = platformKey(pd.platform);
|
||||
const labelNorm = norm(pd.label);
|
||||
const dup = alreadyListed.some((u) => {
|
||||
if (u.pk !== pk) return false;
|
||||
if (!u.nameNorm || !labelNorm) return u.pk === pk;
|
||||
return (
|
||||
u.nameNorm.includes(labelNorm) || labelNorm.includes(u.nameNorm)
|
||||
);
|
||||
});
|
||||
return !dup;
|
||||
})
|
||||
.map((d) => ({
|
||||
source: "protected" as const,
|
||||
deviceId: d.id,
|
||||
hostname: d.label,
|
||||
model: d.platform,
|
||||
osVersion: null as string | null,
|
||||
magicEnrolledAt: (d.installedAt ?? d.createdAt).toISOString(),
|
||||
releaseRequestedAt: null as string | null,
|
||||
releaseAvailableAt: null as string | null,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: [...magicItems, ...protectedItems],
|
||||
data: [...magicItems, ...lockedItems, ...protectedItems],
|
||||
};
|
||||
});
|
||||
|
||||
function prettyPlatform(p: string): string {
|
||||
switch (p.toLowerCase()) {
|
||||
case "ios":
|
||||
return "iPhone / iPad";
|
||||
case "android":
|
||||
return "Android-Ger\u00e4t";
|
||||
case "mac":
|
||||
case "macos":
|
||||
case "darwin":
|
||||
return "Mac";
|
||||
default:
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
27
backend/server/api/magic/me.get.ts
Normal file
27
backend/server/api/magic/me.get.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { usePrisma } from "../../utils/prisma";
|
||||
import { requireUser } from "../../utils/auth";
|
||||
|
||||
/**
|
||||
* GET /api/magic/me
|
||||
*
|
||||
* Profil-Info des eingeloggten Magic-Users f\u00fcr den Hub-Header.
|
||||
* Response: { nickname, avatar, plan }
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
const db = usePrisma();
|
||||
|
||||
const profile = await db.profile.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { nickname: true, username: true, avatar: true, plan: true },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
nickname: profile?.nickname ?? profile?.username ?? null,
|
||||
avatar: profile?.avatar ?? null,
|
||||
plan: profile?.plan ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user