Compare commits

...

13 Commits

Author SHA1 Message Date
d8273e794d Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m0s
Build and Push Docker Images / build-frontend (push) Successful in 1m13s
Build and Push Docker Images / build-website (push) Successful in 20s
Build and Push Docker Images / build-docs (push) Successful in 16s
2026-07-15 12:49:25 +02:00
1711f975bb KI-AGENT: Dropdown zum Einfügen von Dokumentpositionen ergänzt 2026-07-15 10:15:11 +02:00
31ef408b66 Merge remote-tracking branch 'origin/dev' into dev 2026-07-14 16:33:07 +02:00
5f66f81ade Merge remote-tracking branch 'origin/dev' into dev 2026-07-13 21:22:36 +02:00
b3067e0e00 KI-AGENT: Erhöht iOS-Buildnummer nach Icon-Korrektur 2026-07-09 08:13:25 +02:00
e30722995e KI-AGENT: Ergänzt native Expo-Dateisystem-Abhängigkeit 2026-07-09 07:59:28 +02:00
bbef553e6b KI-AGENT: Erhöht iOS-Buildnummer für TestFlight 2026-07-09 07:57:48 +02:00
53632d25ac KI-AGENT: Aktualisiert Expo-Patch-Versionen 2026-07-09 07:46:23 +02:00
2eaa49bf05 KI-AGENT: Aktualisiert Mobile-App-Icons 2026-07-09 07:41:52 +02:00
14026181c2 KI-AGENT: Stabilisiert mobilen Session-Start 2026-07-09 07:34:44 +02:00
d67da7e02a KI-AGENT: Skript zum Löschen von Rechnungsbereichen ergänzen 2026-07-03 20:56:15 +02:00
3b3a879b02 KI-AGENT: Individuellen Aufschlag bei importierten Abschlagsrechnungen verhindern 2026-06-15 10:41:49 +02:00
365cec206d KI-AGENT: Selfhost-Netzwerke beim Setup bereinigen 2026-06-03 15:15:33 +02:00
17 changed files with 1202 additions and 584 deletions

View File

@@ -12,6 +12,7 @@
"start": "node dist/src/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
"invoices:delete-range": "tsx scripts/delete-createddocument-range.ts",
"members:import:csv": "tsx scripts/import-members-csv.ts",
"profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts",
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"

View File

@@ -0,0 +1,359 @@
import "dotenv/config"
import { Pool, type PoolClient } from "pg"
import { loadSecrets, secrets } from "../src/utils/secrets"
type Args = {
tenantId?: number
fromNumber?: string
toNumber?: string
fromId?: number
toId?: number
types: string[]
execute: boolean
unlinkRelated: boolean
}
type TargetDocument = {
id: number
documentNumber: string | null
type: string
title: string | null
state: string
}
const DEFAULT_TYPES = ["invoices"]
function printUsage() {
console.log(`
Löscht einen Bereich von Ausgangsbelegen und deren direkt verknüpfte DB-Dokumente.
Standardmäßig läuft das Skript als Dry-Run. Erst --execute löscht wirklich.
Beispiele:
npm run invoices:delete-range -- --tenant 41 --from RE-2026-001 --to RE-2026-010
npm run invoices:delete-range -- --tenant 41 --from-id 1200 --to-id 1210 --execute
npm run invoices:delete-range -- --tenant 41 --from RE-2026-001 --to RE-2026-010 --type invoices --type advanceInvoices --execute
Optionen:
--tenant <id> Pflicht: Tenant-ID
--from <nummer> Erste Rechnungsnummer im Bereich
--to <nummer> Letzte Rechnungsnummer im Bereich
--from-id <id> Erste Beleg-ID im Bereich
--to-id <id> Letzte Beleg-ID im Bereich
--type <type> Belegtyp, mehrfach möglich. Standard: invoices
--execute Wirklich löschen. Ohne diese Option nur Vorschau.
--unlink-related Externe Belege, die auf Zielbelege zeigen, werden entkoppelt.
--help Hilfe anzeigen
`)
}
function readValue(args: string[], index: number, flag: string) {
const value = args[index + 1]
if (!value || value.startsWith("--")) {
throw new Error(`Für ${flag} fehlt ein Wert.`)
}
return value
}
function readNumber(args: string[], index: number, flag: string) {
const value = Number(readValue(args, index, flag))
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${flag} muss eine positive Ganzzahl sein.`)
}
return value
}
function parseArgs(argv: string[]): Args {
const parsed: Args = {
types: [],
execute: false,
unlinkRelated: false,
}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (arg === "--help" || arg === "-h") {
printUsage()
process.exit(0)
}
if (arg === "--tenant") {
parsed.tenantId = readNumber(argv, i, arg)
i += 1
continue
}
if (arg === "--from") {
parsed.fromNumber = readValue(argv, i, arg)
i += 1
continue
}
if (arg === "--to") {
parsed.toNumber = readValue(argv, i, arg)
i += 1
continue
}
if (arg === "--from-id") {
parsed.fromId = readNumber(argv, i, arg)
i += 1
continue
}
if (arg === "--to-id") {
parsed.toId = readNumber(argv, i, arg)
i += 1
continue
}
if (arg === "--type") {
parsed.types.push(readValue(argv, i, arg))
i += 1
continue
}
if (arg === "--execute") {
parsed.execute = true
continue
}
if (arg === "--unlink-related") {
parsed.unlinkRelated = true
continue
}
throw new Error(`Unbekannte Option: ${arg}`)
}
if (!parsed.tenantId) throw new Error("--tenant ist Pflicht.")
const hasNumberRange = parsed.fromNumber !== undefined || parsed.toNumber !== undefined
const hasIdRange = parsed.fromId !== undefined || parsed.toId !== undefined
if (hasNumberRange && hasIdRange) {
throw new Error("Bitte entweder Rechnungsnummernbereich oder ID-Bereich verwenden, nicht beides.")
}
if (hasNumberRange && (!parsed.fromNumber || !parsed.toNumber)) {
throw new Error("Für einen Rechnungsnummernbereich sind --from und --to Pflicht.")
}
if (hasIdRange && (!parsed.fromId || !parsed.toId)) {
throw new Error("Für einen ID-Bereich sind --from-id und --to-id Pflicht.")
}
if (!hasNumberRange && !hasIdRange) {
throw new Error("Bitte einen Bereich mit --from/--to oder --from-id/--to-id angeben.")
}
if (parsed.fromNumber && parsed.toNumber && parsed.fromNumber > parsed.toNumber) {
throw new Error("--from darf bei Textvergleich nicht größer als --to sein.")
}
if (parsed.fromId && parsed.toId && parsed.fromId > parsed.toId) {
throw new Error("--from-id darf nicht größer als --to-id sein.")
}
if (parsed.types.length === 0) parsed.types = DEFAULT_TYPES
return parsed
}
async function loadConnectionString() {
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
await loadSecrets()
if (secrets.DATABASE_URL) return secrets.DATABASE_URL
throw new Error("DATABASE_URL ist nicht konfiguriert.")
}
function buildTargetWhere(args: Args, startParam = 1) {
const values: unknown[] = [args.tenantId, args.types]
const clauses = [
`"tenant" = $${startParam}`,
`"type" = ANY($${startParam + 1}::text[])`,
]
if (args.fromNumber && args.toNumber) {
values.push(args.fromNumber, args.toNumber)
clauses.push(`"documentNumber" >= $${startParam + 2}`)
clauses.push(`"documentNumber" <= $${startParam + 3}`)
} else {
values.push(args.fromId, args.toId)
clauses.push(`"id" >= $${startParam + 2}`)
clauses.push(`"id" <= $${startParam + 3}`)
}
return {
values,
whereSql: clauses.join(" AND "),
}
}
async function selectTargetDocuments(client: PoolClient, args: Args) {
const target = buildTargetWhere(args)
const result = await client.query<TargetDocument>(
`
SELECT "id", "documentNumber", "type", "title", "state"
FROM "createddocuments"
WHERE ${target.whereSql}
ORDER BY "id"
`,
target.values
)
return result.rows
}
async function countByTarget(client: PoolClient, table: string, column: string, targetIds: number[]) {
const result = await client.query<{ count: string }>(
`SELECT COUNT(*)::int AS "count" FROM "${table}" WHERE "${column}" = ANY($1::bigint[])`,
[targetIds]
)
return Number(result.rows[0]?.count || 0)
}
async function findExternalLinkedDocuments(client: PoolClient, targetIds: number[]) {
const result = await client.query<TargetDocument>(
`
SELECT "id", "documentNumber", "type", "title", "state"
FROM "createddocuments"
WHERE "linkedDocument" = ANY($1::bigint[])
AND NOT ("id" = ANY($1::bigint[]))
ORDER BY "id"
`,
[targetIds]
)
return result.rows
}
async function deleteByTarget(client: PoolClient, table: string, column: string, targetIds: number[]) {
const result = await client.query(
`DELETE FROM "${table}" WHERE "${column}" = ANY($1::bigint[])`,
[targetIds]
)
return result.rowCount || 0
}
function printTargets(targets: TargetDocument[]) {
console.table(
targets.map((item) => ({
id: item.id,
nummer: item.documentNumber,
typ: item.type,
status: item.state,
titel: item.title,
}))
)
}
async function run() {
const args = parseArgs(process.argv.slice(2))
const connectionString = await loadConnectionString()
const pool = new Pool({ connectionString, max: 1 })
const client = await pool.connect()
try {
await client.query("BEGIN")
const targets = await selectTargetDocuments(client, args)
if (targets.length === 0) {
console.log("Keine passenden Rechnungen gefunden.")
await client.query("ROLLBACK")
return
}
const targetIds = targets.map((item) => item.id)
const [fileCount, historyCount, allocationCount, externalLinks] = await Promise.all([
countByTarget(client, "files", "createddocument", targetIds),
countByTarget(client, "historyitems", "createddocument", targetIds),
countByTarget(client, "statementallocations", "cd_id", targetIds),
findExternalLinkedDocuments(client, targetIds),
])
console.log(`Tenant: ${args.tenantId}`)
console.log(`Belegtypen: ${args.types.join(", ")}`)
console.log(`Modus: ${args.execute ? "Löschen" : "Dry-Run"}`)
console.log("")
printTargets(targets)
console.log("")
console.log("Betroffene verknüpfte DB-Zeilen:")
console.log(`- Dateien: ${fileCount}`)
console.log(`- Historie: ${historyCount}`)
console.log(`- Bank-Zuordnungen: ${allocationCount}`)
if (externalLinks.length > 0 && !args.unlinkRelated) {
console.log("")
console.log("Abbruch: Andere Belege verweisen auf Zielbelege.")
console.log("Nutze --unlink-related, wenn diese Verknüpfungen vor dem Löschen entfernt werden sollen.")
printTargets(externalLinks)
await client.query("ROLLBACK")
process.exitCode = 1
return
}
if (!args.execute) {
console.log("")
console.log("Dry-Run abgeschlossen. Es wurde nichts gelöscht. Zum Löschen --execute ergänzen.")
await client.query("ROLLBACK")
return
}
if (externalLinks.length > 0) {
await client.query(
`
UPDATE "createddocuments"
SET "linkedDocument" = NULL
WHERE "linkedDocument" = ANY($1::bigint[])
AND NOT ("id" = ANY($1::bigint[]))
`,
[targetIds]
)
}
await client.query(
`UPDATE "createddocuments" SET "linkedDocument" = NULL WHERE "id" = ANY($1::bigint[])`,
[targetIds]
)
const deletedFiles = await deleteByTarget(client, "files", "createddocument", targetIds)
const deletedHistory = await deleteByTarget(client, "historyitems", "createddocument", targetIds)
const deletedAllocations = await deleteByTarget(client, "statementallocations", "cd_id", targetIds)
const deletedDocuments = await client.query(
`DELETE FROM "createddocuments" WHERE "id" = ANY($1::bigint[])`,
[targetIds]
)
await client.query("COMMIT")
console.log("")
console.log("Löschung abgeschlossen:")
console.log(`- Rechnungen: ${deletedDocuments.rowCount || 0}`)
console.log(`- Dateien: ${deletedFiles}`)
console.log(`- Historie: ${deletedHistory}`)
console.log(`- Bank-Zuordnungen: ${deletedAllocations}`)
if (externalLinks.length > 0) {
console.log(`- Entkoppelte externe Belege: ${externalLinks.length}`)
}
} catch (error) {
await client.query("ROLLBACK")
throw error
} finally {
client.release()
await pool.end()
}
}
run().catch((error) => {
console.error("Fehler beim Löschen der Rechnungen:")
console.error(error)
process.exit(1)
})

View File

@@ -351,6 +351,10 @@ const normalizeCreatedDocumentRow = (row) => {
const normalizeCreatedDocumentRows = (rows) => Array.isArray(rows)
? rows.map((row) => normalizeCreatedDocumentRow(row))
: []
const isImportedAdvanceInvoiceRow = (row) => {
return Array.isArray(row.linkedEntitys)
&& row.linkedEntitys.some(i => i.type === "createddocuments" && i.subtype === "advanceInvoices")
}
const setupPage = async () => {
await setupData()
@@ -507,6 +511,8 @@ const setupPage = async () => {
})
for await (const doc of linkedDocuments.filter(i => i.type === "advanceInvoices")) {
const advanceInvoiceNet = useSum().getCreatedDocumentSumDetailed(doc).totalNet * -1
itemInfo.value.rows.push({
mode: "free",
text: `Abschlagsrechnung ${doc.documentNumber}`,
@@ -514,7 +520,8 @@ const setupPage = async () => {
taxPercent: 19, // TODO TAX PERCENTAGE
discountPercent: 0,
unit: 10,
inputPrice: useSum().getCreatedDocumentSumDetailed(doc).totalNet * -1,
inputPrice: advanceInvoiceNet,
price: advanceInvoiceNet,
linkedEntitys: [
{
type: "createddocuments",
@@ -819,21 +826,18 @@ const getRowAmountUndiscounted = (row) => {
return String(Number(Number(row.quantity) * Number(row.price)).toFixed(2)).replace('.', ',')
}
const addPosition = (mode) => {
let lastId = 0
itemInfo.value.rows.forEach(row => {
if (row.id > lastId) lastId = row.id
})
let taxPercentage = 19
if (['13b UStG', '19 UStG', '12.3 UStG'].includes(itemInfo.value.taxType)) {
taxPercentage = 0
}
const positionAddOptions = [
{ mode: 'service', label: 'Leistung', icon: 'i-heroicons-wrench-screwdriver' },
{ mode: 'normal', label: 'Artikel', icon: 'i-heroicons-cube' },
{ mode: 'free', label: 'Freie Position', icon: 'i-heroicons-pencil-square' },
{ mode: 'pagebreak', label: 'Seitenumbruch', icon: 'i-heroicons-document-minus' },
{ mode: 'title', label: 'Titel', icon: 'i-heroicons-bars-3-bottom-left' },
{ mode: 'text', label: 'Text', icon: 'i-heroicons-document-text' }
]
const createPositionRow = (mode, taxPercentage) => {
if (mode === 'free') {
let rowData = {
const rowData = {
id: uuidv4(),
mode: "free",
text: "",
@@ -848,10 +852,11 @@ const addPosition = (mode) => {
linkedEntitys: []
}
itemInfo.value.rows.push({...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}})
return {...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}}
}
} else if (mode === 'normal') {
itemInfo.value.rows.push({
if (mode === 'normal') {
return {
id: uuidv4(),
mode: "normal",
quantity: 1,
@@ -862,9 +867,11 @@ const addPosition = (mode) => {
unit: 1,
costCentre: itemInfo.value.costcentre,
linkedEntitys: []
})
} else if (mode === 'service') {
let rowData = {
}
}
if (mode === 'service') {
const rowData = {
id: uuidv4(),
mode: "service",
quantity: 1,
@@ -877,26 +884,63 @@ const addPosition = (mode) => {
linkedEntitys: []
}
//Push Agriculture Holder only if Module is activated
itemInfo.value.rows.push({...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}})
} else if (mode === "pagebreak") {
itemInfo.value.rows.push({
return {...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}}
}
if (mode === "pagebreak") {
return {
id: uuidv4(),
mode: "pagebreak",
linkedEntitys: []
})
} else if (mode === "title") {
itemInfo.value.rows.push({
}
}
if (mode === "title") {
return {
id: uuidv4(),
mode: "title",
linkedEntitys: []
})
} else if (mode === "text") {
itemInfo.value.rows.push({
}
}
if (mode === "text") {
return {
id: uuidv4(),
mode: "text",
linkedEntitys: []
})
}
}
return null
}
const getPositionAddMenuItems = (row) => [
positionAddOptions.map(option => ({
...option,
onSelect: () => addPosition(option.mode, row?.id)
}))
]
const getFullWidthPositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 5 : 8
const getTitlePositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 4 : 7
const addPosition = (mode, afterRowId = null) => {
let taxPercentage = 19
if (['13b UStG', '19 UStG', '12.3 UStG'].includes(itemInfo.value.taxType)) {
taxPercentage = 0
}
const newRow = createPositionRow(mode, taxPercentage)
if (!newRow) return
const insertAfterIndex = afterRowId ? itemInfo.value.rows.findIndex(row => row.id === afterRowId) : -1
if (insertAfterIndex >= 0) {
itemInfo.value.rows.splice(insertAfterIndex + 1, 0, newRow)
} else {
itemInfo.value.rows.push(newRow)
}
setPosNumbers()
@@ -1799,15 +1843,15 @@ const checkCompatibilityWithInputPrice = () => {
const updateCustomSurcharge = () => {
itemInfo.value.rows.forEach(row => {
if (!["pagebreak", "title", "text"].includes(row.mode) /*&& !row.linkedEntitys.find(i => i.type === "createddocuments" && i.subtype === "advanceInvoices")*/) {
if (!["pagebreak", "title", "text"].includes(row.mode) && isImportedAdvanceInvoiceRow(row)) {
row.price = Number(row.inputPrice || 0)
} else if (!["pagebreak", "title", "text"].includes(row.mode)) {
//setRowData(row)
row.price = Number((row.inputPrice * (1 + itemInfo.value.customSurchargePercentage / 100)).toFixed(2))
}/* else if(!["pagebreak","title","text"].includes(row.mode) /!*&& row.linkedEntitys.find(i => i.type === "createddocuments" && i.subtype === "advanceInvoices")*!/) {
row.price = row.inputPrice
}*/
}
})
}
@@ -2790,6 +2834,8 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
<th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">Rabatt</th>-->
<th class="pl-2"></th>
<th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">Gesamt</th>
<th class="pl-2"></th>
<th class="pl-2"></th>
</tr>
</thead>
<draggable
@@ -2810,13 +2856,13 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
</td>
<td
v-if="row.mode === 'pagebreak'"
colspan="8"
:colspan="getFullWidthPositionColspan()"
>
<USeparator/>
</td>
<td
v-if="row.mode === 'text'"
colspan="8"
:colspan="getFullWidthPositionColspan()"
>
<!-- <UInput
v-model="row.text"
@@ -3289,7 +3335,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
</InputGroup>
<UAlert
v-if="row.linkedEntitys && row.linkedEntitys.find(i => i.type === 'createddocuments' && i.subtype === 'advanceInvoices')"
v-if="isImportedAdvanceInvoiceRow(row)"
title="Berechnung des Individuellen Aufschlags für die Zeile gesperrt"
color="orange"
variant="subtle"
@@ -3423,7 +3469,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
</td>
<td
v-if="row.mode === 'title'"
colspan="7"
:colspan="getTitlePositionColspan()"
>
<UInput
:disabled="itemInfo.type === 'cancellationInvoices'"
@@ -3432,6 +3478,19 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
class="w-full"
/>
</td>
<td>
<UDropdownMenu
:items="getPositionAddMenuItems(row)"
:content="{ align: 'end' }"
>
<UButton
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
variant="ghost"
color="primary"
icon="i-heroicons-plus"
/>
</UDropdownMenu>
</td>
<td>
<UButton
:disabled="itemInfo.type === 'cancellationInvoices'"
@@ -3455,46 +3514,13 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
<InputGroup>
<UButton
@click="addPosition('service')"
v-for="option in positionAddOptions"
:key="option.mode"
@click="addPosition(option.mode)"
class="mt-3"
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
>
+ Leistung
</UButton>
<UButton
@click="addPosition('normal')"
class="mt-3"
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
>
+ Artikel
</UButton>
<UButton
@click="addPosition('free')"
class="mt-3"
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
>
+ Freie Position
</UButton>
<UButton
@click="addPosition('pagebreak')"
class="mt-3"
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
>
+ Seitenumbruch
</UButton>
<UButton
@click="addPosition('title')"
class="mt-3"
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
>
+ Titel
</UButton>
<UButton
@click="addPosition('text')"
class="mt-3"
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
>
+ Text
+ {{ option.label }}
</UButton>
</InputGroup>

View File

@@ -11,7 +11,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "software.federspiel.fedeo",
"buildNumber": "5",
"buildNumber": "7",
"infoPlist": {
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
@@ -28,9 +28,8 @@
"android.permission.ACCESS_FINE_LOCATION"
],
"adaptiveIcon": {
"backgroundColor": "#69c350",
"backgroundColor": "#ffffff",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
@@ -50,7 +49,8 @@
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#0f1f0d"
"image": "./assets/images/splash-icon-dark.png",
"backgroundColor": "#000000"
}
}
],

View File

@@ -1,10 +1,10 @@
import { Redirect } from 'expo-router';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
import { useAuth } from '@/src/providers/auth-provider';
export default function IndexScreen() {
const { isBootstrapping, token, requiresTenantSelection } = useAuth();
const { isBootstrapping, bootstrapError, token, requiresTenantSelection, retryBootstrap, resetLocalSession } = useAuth();
if (isBootstrapping) {
return (
@@ -15,6 +15,23 @@ export default function IndexScreen() {
);
}
if (bootstrapError) {
return (
<View style={styles.centered}>
<Text style={styles.title}>Session konnte nicht gestartet werden</Text>
<Text style={styles.copy}>{bootstrapError}</Text>
<View style={styles.actions}>
<Pressable style={styles.primaryButton} onPress={retryBootstrap}>
<Text style={styles.primaryButtonText}>Erneut versuchen</Text>
</Pressable>
<Pressable style={styles.secondaryButton} onPress={resetLocalSession}>
<Text style={styles.secondaryButtonText}>Lokale Session löschen</Text>
</Pressable>
</View>
</View>
);
}
if (!token) {
return <Redirect href="/login" />;
}
@@ -37,5 +54,43 @@ const styles = StyleSheet.create({
copy: {
fontSize: 14,
color: '#4b5563',
textAlign: 'center',
},
title: {
fontSize: 20,
fontWeight: '700',
color: '#111827',
textAlign: 'center',
},
actions: {
width: '100%',
gap: 10,
marginTop: 4,
},
primaryButton: {
minHeight: 44,
borderRadius: 10,
backgroundColor: '#69c350',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 16,
},
primaryButtonText: {
color: '#ffffff',
fontWeight: '700',
},
secondaryButton: {
minHeight: 44,
borderRadius: 10,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 16,
},
secondaryButtonText: {
color: '#374151',
fontWeight: '600',
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 39 KiB

980
mobile/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,17 +25,18 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.34",
"expo": "~54.0.35",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-document-picker": "^14.0.8",
"expo-font": "~14.0.11",
"expo-file-system": "~19.0.23",
"expo-font": "~14.0.12",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.12",
"expo-notifications": "~0.32.17",
"expo-router": "~6.0.23",
"expo-router": "~6.0.24",
"expo-secure-store": "^15.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",

View File

@@ -196,6 +196,7 @@ type RequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
token?: string | null;
body?: unknown;
timeoutMs?: number;
};
export type MatrixStatus = {
@@ -314,17 +315,46 @@ async function parseJson(response: Response): Promise<unknown> {
}
}
const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
function createTimeoutSignal(timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
return {
signal: controller.signal,
cleanup: () => clearTimeout(timeout),
};
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const hasJsonBody = options.body !== undefined;
const response = await fetch(buildUrl(path), {
method: options.method || 'GET',
headers: {
Accept: 'application/json',
...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}),
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
},
body: hasJsonBody ? JSON.stringify(options.body) : undefined,
});
const { signal, cleanup } = createTimeoutSignal(options.timeoutMs);
let response: Response;
try {
response = await fetch(buildUrl(path), {
method: options.method || 'GET',
headers: {
Accept: 'application/json',
...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}),
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
},
body: hasJsonBody ? JSON.stringify(options.body) : undefined,
signal,
});
} catch (error) {
if (isAbortError(error)) {
throw new Error(`Zeitüberschreitung beim Verbinden mit dem FEDEO-Server (${path}).`);
}
throw error;
} finally {
cleanup();
}
const payload = await parseJson(response);
@@ -340,14 +370,27 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
}
async function apiFormRequest<T>(path: string, token: string, formData: FormData): Promise<T> {
const response = await fetch(buildUrl(path), {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
body: formData,
});
const { signal, cleanup } = createTimeoutSignal();
let response: Response;
try {
response = await fetch(buildUrl(path), {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
body: formData,
signal,
});
} catch (error) {
if (isAbortError(error)) {
throw new Error(`Zeitüberschreitung beim Hochladen zum FEDEO-Server (${path}).`);
}
throw error;
} finally {
cleanup();
}
const payload = await parseJson(response);

View File

@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api';
import { hydrateApiBaseUrl } from '@/src/lib/server-config';
@@ -8,6 +8,7 @@ export type AuthUser = MeResponse['user'];
type AuthContextValue = {
isBootstrapping: boolean;
bootstrapError: string | null;
token: string | null;
user: AuthUser | null;
tenants: Tenant[];
@@ -20,9 +21,12 @@ type AuthContextValue = {
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
switchTenant: (tenantId: number) => Promise<void>;
retryBootstrap: () => Promise<void>;
resetLocalSession: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
const BOOTSTRAP_TIMEOUT_MS = 20000;
function normalizeTenantId(value: number | string | null | undefined): number | null {
if (value === null || value === undefined || value === '') {
@@ -33,14 +37,27 @@ function normalizeTenantId(value: number | string | null | undefined): number |
return Number.isNaN(parsed) ? null : parsed;
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then(resolve)
.catch(reject)
.finally(() => clearTimeout(timeout));
});
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isBootstrapping, setIsBootstrapping] = useState(true);
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
const [profile, setProfile] = useState<Record<string, unknown> | null>(null);
const [permissions, setPermissions] = useState<string[]>([]);
const bootstrapRunRef = useRef(0);
const resetSession = useCallback(() => {
setUser(null);
@@ -65,6 +82,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const logout = useCallback(async () => {
await clearStoredToken();
setToken(null);
setBootstrapError(null);
resetSession();
}, [resetSession]);
@@ -81,31 +99,59 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}, [hydrateSession, logout, resetSession, token]);
useEffect(() => {
async function bootstrap() {
await hydrateApiBaseUrl();
const storedToken = await getStoredToken();
const resetLocalSession = useCallback(async () => {
setToken(null);
setBootstrapError(null);
setIsBootstrapping(false);
resetSession();
void clearStoredToken().catch(() => undefined);
}, [resetSession]);
if (!storedToken) {
setIsBootstrapping(false);
return;
}
const bootstrap = useCallback(async () => {
const runId = bootstrapRunRef.current + 1;
bootstrapRunRef.current = runId;
setIsBootstrapping(true);
setBootstrapError(null);
try {
await hydrateSession(storedToken);
setToken(storedToken);
} catch {
await clearStoredToken();
try {
await withTimeout(
(async () => {
await hydrateApiBaseUrl();
const storedToken = await getStoredToken();
if (!storedToken) {
if (bootstrapRunRef.current !== runId) return;
setToken(null);
resetSession();
return;
}
await hydrateSession(storedToken);
if (bootstrapRunRef.current !== runId) return;
setToken(storedToken);
})(),
BOOTSTRAP_TIMEOUT_MS,
'Die mobile Session konnte nicht rechtzeitig initialisiert werden.'
);
} catch (err) {
if (bootstrapRunRef.current === runId) {
setToken(null);
resetSession();
} finally {
setBootstrapError(err instanceof Error ? err.message : 'Die mobile Session konnte nicht initialisiert werden.');
void clearStoredToken().catch(() => undefined);
}
} finally {
if (bootstrapRunRef.current === runId) {
setIsBootstrapping(false);
}
}
void bootstrap();
}, [hydrateSession, resetSession]);
useEffect(() => {
void bootstrap();
}, [bootstrap]);
const login = useCallback(
async (email: string, password: string) => {
const nextToken = await loginWithEmailPassword(email, password);
@@ -140,6 +186,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const value = useMemo(
() => ({
isBootstrapping,
bootstrapError,
token,
user,
tenants,
@@ -152,10 +199,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
logout,
refreshUser,
switchTenant,
retryBootstrap: bootstrap,
resetLocalSession,
}),
[
activeTenant,
activeTenantId,
bootstrap,
bootstrapError,
isBootstrapping,
login,
logout,
@@ -163,6 +214,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
profile,
refreshUser,
requiresTenantSelection,
resetLocalSession,
switchTenant,
tenants,
token,

View File

@@ -30,6 +30,14 @@ read_interactive() {
fi
}
docker_engine() {
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
sudo docker "$@"
else
docker "$@"
fi
}
compose() {
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
sudo docker compose "$@"
@@ -44,12 +52,27 @@ compose_stack() {
compose_start_command() {
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
echo "sudo docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d"
echo "sudo docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d --remove-orphans"
else
echo "docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d"
echo "docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d --remove-orphans"
fi
}
remove_selfhost_networks() {
local network
for network in fedeo_web fedeo_internal; do
if docker_engine network inspect "$network" >/dev/null 2>&1; then
if docker_engine network rm "$network" >/dev/null 2>&1; then
echo "Docker-Netzwerk entfernt: $network"
else
echo "Docker-Netzwerk konnte nicht entfernt werden: $network"
echo "Prüfe laufende Container mit: docker network inspect $network"
fi
fi
done
}
usage() {
cat <<'USAGE'
FEDEO Selfhost Setup
@@ -461,6 +484,8 @@ uninstall_stack() {
"$ROOT_DIR/traefik/letsencrypt" \
"$ROOT_DIR/traefik/logs"
remove_selfhost_networks
echo
echo "Uninstall inklusive lokaler Daten abgeschlossen."
}
@@ -597,7 +622,7 @@ main() {
fi
if [[ "$START_STACK" == "yes" ]]; then
compose_stack up -d
compose_stack up -d --remove-orphans
else
echo
echo "Start später mit:"