diff --git a/apps/admin/start-admin-staging.sh b/apps/admin/start-admin-staging.sh new file mode 100755 index 0000000..3ef2668 --- /dev/null +++ b/apps/admin/start-admin-staging.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# apps/admin/start-admin-staging.sh -- startet rebreak-admin-staging mit Infisical-Secrets. +# +# Pattern: identisch zu backend/start-staging.sh. +# Admin-App braucht: NUXT_PUBLIC_SUPABASE_URL, NUXT_PUBLIC_SUPABASE_KEY, ADMIN_SECRET. +# Alles via Infisical env=staging -- selbes Infisical-Project wie backend. + +set -euo pipefail +source /etc/environment + +if [[ -z "${INFISICAL_CLIENT_ID:-}" || -z "${INFISICAL_CLIENT_SECRET:-}" ]]; then + echo "[start-admin-staging] FEHLER: INFISICAL_CLIENT_ID / SECRET nicht gesetzt" >&2 + exit 1 +fi + +INFISICAL_TOKEN=$(infisical login \ + --method=universal-auth \ + --client-id="${INFISICAL_CLIENT_ID}" \ + --client-secret="${INFISICAL_CLIENT_SECRET}" \ + --silent --plain 2>/dev/null) + +[[ -z "$INFISICAL_TOKEN" ]] && { echo "[start-admin-staging] Infisical login fehlgeschlagen" >&2; exit 1; } + +export NODE_ENV=production +export NITRO_PORT=3017 +export NITRO_HOST=127.0.0.1 +export PORT=3017 + +NODE_BIN="/root/.nvm/versions/node/v24.11.1/bin/node" +INDEX_MJS="/srv/rebreak/apps/admin/.output-staging/server/index.mjs" + +[[ ! -f "$INDEX_MJS" ]] && { + echo "[start-admin-staging] FEHLER: $INDEX_MJS fehlt -- deploy-admin-from-artifact.sh laufen lassen" >&2 + exit 1 +} + +exec infisical run \ + --projectId="${INFISICAL_PROJECT_ID:-14b11b35-ef59-4b8a-a16b-398f0cc3ad93}" \ + --env=staging \ + --token="$INFISICAL_TOKEN" \ + -- bash -c ' + set -e + # ─── Infisical-Vars auf Nuxt-runtimeConfig-Namen mappen ────────────── + # Supabase (public -- aus Infisical staging geladen, selbe Keys wie backend) + [[ -n "${SUPABASE_URL:-}" ]] && export NUXT_PUBLIC_SUPABASE_URL="$SUPABASE_URL" + [[ -n "${SUPABASE_KEY:-}" ]] && export NUXT_PUBLIC_SUPABASE_KEY="$SUPABASE_KEY" + # Admin-Secret (server-only, fuer requireAdmin-Middleware Phase 3) + [[ -n "${ADMIN_SECRET:-}" ]] && export NUXT_ADMIN_SECRET="$ADMIN_SECRET" + # Backend-API-Base (admin-app zeigt auf backend-staging) + [[ -n "${NUXT_PUBLIC_API_BASE:-}" ]] && export NUXT_PUBLIC_API_BASE="$NUXT_PUBLIC_API_BASE" + + exec '"$NODE_BIN"' '"$INDEX_MJS"' + ' diff --git a/apps/rebreak-native/app/profile/edit.tsx b/apps/rebreak-native/app/profile/edit.tsx new file mode 100644 index 0000000..1b2a027 --- /dev/null +++ b/apps/rebreak-native/app/profile/edit.tsx @@ -0,0 +1,318 @@ +import { useState } from 'react'; +import { + View, + Text, + TextInput, + Pressable, + ScrollView, + Image, + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Alert, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import * as ImagePicker from 'expo-image-picker'; +// TODO(sdk54): migrate to new expo-file-system class-based API — see Task #14 +import * as FileSystem from 'expo-file-system/legacy'; +import { useTranslation } from 'react-i18next'; +import { colors } from '../../lib/theme'; +import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars'; +import { resolveAvatar } from '../../lib/resolveAvatar'; +import { apiFetch } from '../../lib/api'; +import { useMe } from '../../hooks/useMe'; + +const INPUT_STYLE = { + fontSize: 16, + lineHeight: 22, + paddingVertical: 14, + paddingHorizontal: 16, + color: colors.text, + fontFamily: 'Nunito_400Regular', + backgroundColor: '#f5f5f5', + borderRadius: 12, +} as const; + +export default function ProfileEditScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + const { me, reload } = useMe(); + + const [nickname, setNickname] = useState(me?.nickname ?? ''); + const [avatarId, setAvatarId] = useState(me?.avatar ?? null); + const [photoUri, setPhotoUri] = useState(null); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); + + const displayAvatar = photoUri ?? avatarId; + + async function pickPhoto() { + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== 'granted') { + Alert.alert( + t('profile.edit_photo_perm_title'), + t('profile.edit_photo_perm_desc'), + ); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: true, + aspect: [1, 1], + quality: 0.7, + }); + + if (result.canceled || !result.assets[0]) return; + + const uri = result.assets[0].uri; + setPhotoUri(uri); + setAvatarId(null); + } + + async function save() { + if (!nickname.trim()) return; + setSaving(true); + + try { + let finalAvatar: string | null = avatarId; + + if (photoUri) { + setUploading(true); + const base64 = await FileSystem.readAsStringAsync(photoUri, { + encoding: FileSystem.EncodingType.Base64, + }); + const ext = photoUri.toLowerCase().endsWith('.png') ? 'png' : 'jpeg'; + const dataUrl = `data:image/${ext};base64,${base64}`; + const res = await apiFetch<{ url: string }>('/api/avatar/upload', { + method: 'POST', + body: { dataUrl }, + }); + finalAvatar = res.url; + setUploading(false); + } + + await apiFetch('/api/auth/me', { + method: 'PATCH', + body: { + nickname: nickname.trim(), + ...(finalAvatar !== me?.avatar ? { avatar: finalAvatar } : {}), + }, + }); + + reload(); + router.back(); + } catch { + setUploading(false); + Alert.alert(t('common.error'), t('common.unknown_error')); + } finally { + setSaving(false); + } + } + + const resolvedPreview = photoUri + ? photoUri + : resolveAvatar(avatarId, nickname || (me?.nickname ?? '')); + + const hasChanges = + nickname.trim() !== (me?.nickname ?? '') || + photoUri !== null || + avatarId !== me?.avatar; + + return ( + + + router.back()} + hitSlop={10} + style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, marginRight: 12 })} + > + + + + {t('profile.edit_title')} + + ({ + opacity: pressed || saving || !hasChanges || !nickname.trim() ? 0.4 : 1, + })} + > + {saving ? ( + + ) : ( + + {t('profile.edit_save')} + + )} + + + + + {/* Avatar preview + pick-photo button */} + + + + {uploading ? ( + + + + ) : null} + + + ({ + marginTop: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + opacity: pressed ? 0.5 : 1, + })} + > + + + {t('profile.edit_photo_cta')} + + + + + {/* Preset avatars */} + + + {t('profile.edit_preset_label').toUpperCase()} + + + {HERO_AVATARS.map((avatar) => { + const isSelected = !photoUri && avatarId === avatar.id; + return ( + { + setAvatarId(avatar.id); + setPhotoUri(null); + }} + style={({ pressed }) => ({ + opacity: pressed ? 0.7 : 1, + })} + > + + + ); + })} + + + + {/* Divider */} + + + {/* Nickname */} + + + {t('profile.edit_nickname_label').toUpperCase()} + + + + {t('profile.edit_nickname_hint')} + + + + + ); +} diff --git a/apps/rebreak-native/app/profile/index.tsx b/apps/rebreak-native/app/profile/index.tsx index 57b33d6..e6b8c2e 100644 --- a/apps/rebreak-native/app/profile/index.tsx +++ b/apps/rebreak-native/app/profile/index.tsx @@ -1,6 +1,7 @@ import { useRef, useState } from 'react'; import { View, ScrollView, Text, Alert, findNodeHandle, UIManager } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; import { AppHeader } from '../../components/AppHeader'; import { ProfileHeader, type AuthProvider } from '../../components/profile/ProfileHeader'; import { StatsBar } from '../../components/profile/StatsBar'; @@ -9,7 +10,7 @@ import { StreakSection, type CooldownEntry } from '../../components/profile/Stre import { UrgeStatsCard, type HelpedByEntry } from '../../components/profile/UrgeStatsCard'; import { DemographicsAccordion, type Demographics } from '../../components/profile/DemographicsAccordion'; import { DigaMissionBanner } from '../../components/profile/DigaMissionBanner'; -import { colors } from '../../lib/theme'; +import { useColors } from '../../lib/theme'; import type { Plan } from '../../hooks/useUserPlan'; import { useMe } from '../../hooks/useMe'; import { useAuthStore } from '../../stores/auth'; @@ -83,7 +84,9 @@ function mapHelpedBy(helpedBy: { } export default function ProfileScreen() { + const router = useRouter(); const insets = useSafeAreaInsets(); + const colors = useColors(); const [bannerDismissed, setBannerDismissed] = useState(false); const [demographicsExpanded, setDemographicsExpanded] = useState(false); const { me } = useMe(); @@ -149,7 +152,7 @@ export default function ProfileScreen() { } return ( - + { - Alert.alert( - 'Avatar bearbeiten', - 'Hero-Auswahl + Foto-Upload kommt in der nächsten Iteration.', - ); - }} - onEditNickname={() => { - Alert.alert( - 'Nickname bearbeiten', - 'Inline-Edit + Save kommt in der nächsten Iteration.', - ); - }} + onEditAvatar={() => router.push('/profile/edit')} + onEditNickname={() => router.push('/profile/edit')} /> diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 14b44b5..c6fb7d8 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -666,6 +666,16 @@ "label_other": "Tage", "label_suffix": "clean" }, + "profile": { + "edit_title": "Profil bearbeiten", + "edit_save": "Speichern", + "edit_photo_cta": "Eigenes Foto wählen", + "edit_photo_perm_title": "Foto-Zugriff", + "edit_photo_perm_desc": "Bitte erlaube den Zugriff auf deine Fotos in den iOS-Einstellungen.", + "edit_preset_label": "Avatar wählen", + "edit_nickname_label": "Nickname", + "edit_nickname_hint": "Sichtbar für andere Mitglieder — max. 32 Zeichen." + }, "demographics": { "employment_status_employed": "angestellt", "employment_status_self_employed": "selbständig", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 44d8236..458870e 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -666,6 +666,16 @@ "label_other": "days", "label_suffix": "clean" }, + "profile": { + "edit_title": "Edit profile", + "edit_save": "Save", + "edit_photo_cta": "Choose your own photo", + "edit_photo_perm_title": "Photo access", + "edit_photo_perm_desc": "Please allow access to your photos in iOS Settings.", + "edit_preset_label": "Choose avatar", + "edit_nickname_label": "Nickname", + "edit_nickname_hint": "Visible to other members — max. 32 characters." + }, "demographics": { "employment_status_employed": "employed", "employment_status_self_employed": "self-employed", diff --git a/scripts/deploy-admin-from-artifact.sh b/scripts/deploy-admin-from-artifact.sh new file mode 100755 index 0000000..01fadc1 --- /dev/null +++ b/scripts/deploy-admin-from-artifact.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# deploy-admin-from-artifact.sh -- Server-side Deploy fuer rebreak-admin-staging. +# +# Wird via SSH von .github/workflows/deploy-admin-staging.yml aufgerufen. +# Erwartet: /srv/rebreak/apps/admin/.output-incoming.tar.gz (via scp vom GA-Runner). +# +# Kein Build-Schritt (laeuft auf GA-Runner), kein prisma-migrate (admin hat kein DB-Schema). +# Atomic mv Pattern identisch zu deploy-from-artifact.sh. + +set -euo pipefail + +REPO_ROOT="/srv/rebreak" +ADMIN_DIR="${REPO_ROOT}/apps/admin" +ARTIFACT="${ADMIN_DIR}/.output-incoming.tar.gz" +PM2_BIN="/root/.nvm/versions/node/v24.11.1/bin/pm2" + +log() { echo "[deploy-admin] $(date '+%H:%M:%S') $*"; } +log_err() { echo "[deploy-admin:err] $(date '+%H:%M:%S') $*" >&2; } + +log "=== Rebreak Admin Deploy-from-Artifact gestartet ===" + +export PATH="/root/.nvm/versions/node/v24.11.1/bin:$PATH" + +# 0. Sanity-Check: Artifact muss da sein +[[ -f "$ARTIFACT" ]] || { log_err "Artifact $ARTIFACT fehlt -- abort"; exit 1; } + +# 1. Ziel-Verzeichnis sicherstellen +mkdir -p "${ADMIN_DIR}" + +# 2. Artifact extrahieren -- atomisches mv +log "Step 1: Artifact extrahieren..." +rm -rf "${ADMIN_DIR}/.output-staging-new" +mkdir -p "${ADMIN_DIR}/.output-staging-new" +tar xzf "$ARTIFACT" -C "${ADMIN_DIR}/.output-staging-new" + +# Sanity-Check: Nuxt-SSR-Server muss vorhanden sein +[[ -f "${ADMIN_DIR}/.output-staging-new/server/index.mjs" ]] || { + log_err "Ungueltiges Artifact -- server/index.mjs fehlt" + rm -rf "${ADMIN_DIR}/.output-staging-new" + exit 1 +} + +rm -rf "${ADMIN_DIR}/.output-staging" +mv "${ADMIN_DIR}/.output-staging-new" "${ADMIN_DIR}/.output-staging" +rm -f "$ARTIFACT" +log ".output-staging aktualisiert" + +# 3. pm2 restart (oder erstmaliger Start via ecosystem) +log "Step 2: pm2 restart rebreak-admin-staging..." +"${PM2_BIN}" restart rebreak-admin-staging --update-env 2>/dev/null || \ + "${PM2_BIN}" start "${REPO_ROOT}/ecosystem.config.js" --only rebreak-admin-staging + +log "rebreak-admin-staging restarted" + +# 4. pm2 save +"${PM2_BIN}" save 2>/dev/null || true + +log "=== Admin Deploy erfolgreich ==="