@futo-org/backups-orchestrator-ui 0.38.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/components/backends/dialogs/OAuthDeviceFlowModal.svelte +30 -55
  2. package/dist/components/backups/BackupItem.svelte +20 -4
  3. package/dist/components/backups/BackupItem.svelte.d.ts +1 -0
  4. package/dist/components/backups/BackupsList.svelte +1 -1
  5. package/dist/components/backups/dialogs/ConfigureRepositoryModal.svelte +1 -27
  6. package/dist/components/backups/dialogs/SelectRepositoryModal.svelte +3 -4
  7. package/dist/components/integrations/immich/settings/ImmichSettingsStorage.svelte +5 -9
  8. package/dist/components/onboarding/OnboardingGate.svelte +7 -0
  9. package/dist/components/onboarding/OnboardingLogin.svelte +64 -0
  10. package/dist/components/onboarding/OnboardingLogin.svelte.d.ts +6 -0
  11. package/dist/components/onboarding/restore-point-flow/RestorePointFlow2SelectSnapshot.svelte +4 -2
  12. package/dist/components/util/DeviceFlowAction.svelte +21 -0
  13. package/dist/components/util/DeviceFlowAction.svelte.d.ts +7 -0
  14. package/dist/components/util/DeviceFlowCode.svelte +36 -0
  15. package/dist/components/util/DeviceFlowCode.svelte.d.ts +7 -0
  16. package/dist/fetch-client.d.ts +32 -8
  17. package/dist/fetch-client.js +20 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/services/backend.service.d.ts +0 -2
  21. package/dist/services/backend.service.js +1 -15
  22. package/dist/services/deviceFlow.service.svelte.d.ts +19 -0
  23. package/dist/services/deviceFlow.service.svelte.js +70 -0
  24. package/dist/services/repository.service.d.ts +4 -5
  25. package/dist/services/repository.service.js +47 -8
  26. package/dist/services/session.service.d.ts +4 -0
  27. package/dist/services/session.service.js +19 -0
  28. package/package.json +2 -2
@@ -1,26 +1,21 @@
1
1
  <script lang="ts">
2
- import Suspense from "../../util/Suspense.svelte";
2
+ import DeviceFlowAction from "../../util/DeviceFlowAction.svelte";
3
+ import DeviceFlowCode from "../../util/DeviceFlowCode.svelte";
3
4
  import type { SocketEvent } from "../../../events";
4
5
  import { type BackendDto } from "../../../fetch-client";
5
- import {
6
- useBackendEventHandler,
7
- useDeviceFlow,
8
- } from "../../../services/backend.service";
6
+ import { useBackendEventHandler } from "../../../services/backend.service";
7
+ import { createDeviceFlow } from "../../../services/deviceFlow.service.svelte";
8
+ import { useCreateSession } from "../../../services/session.service";
9
9
  import {
10
10
  Button,
11
- Code,
12
11
  HStack,
13
- IconButton,
14
12
  LoadingSpinner,
15
13
  Modal,
16
14
  ModalBody,
17
15
  ModalFooter,
18
- Stack,
19
16
  Text,
20
- toastManager,
21
- VStack,
22
17
  } from "@immich/ui";
23
- import { mdiContentCopy } from "@mdi/js";
18
+ import { onDestroy } from "svelte";
24
19
  import OnEvents from "../../util/OnEvents.svelte";
25
20
 
26
21
  type Props = {
@@ -30,67 +25,47 @@
30
25
 
31
26
  let { onCreate, onClose }: Props = $props();
32
27
 
33
- const uid = $props.id();
34
- const query = useDeviceFlow(uid);
35
-
36
28
  const { onBackendCreate: onBackendCreateHandler } = useBackendEventHandler();
37
29
 
38
- function onBackendCreate(event: SocketEvent<{ backend: BackendDto }>) {
39
- onBackendCreateHandler(event);
40
- onCreate?.(event.data.backend.id);
41
- onClose();
42
- }
30
+ const session = useCreateSession();
43
31
 
44
- function onDeviceFlowFailure() {
45
- toastManager.danger("Failed to log into FUTO Backups");
46
- onClose();
47
- }
32
+ const flow = createDeviceFlow("oidc", {
33
+ createSession: (token) => session.mutateAsync(token),
34
+ onComplete: (event) => {
35
+ if (event.backendId) {
36
+ onCreate?.(event.backendId);
37
+ }
48
38
 
49
- function onRetry() {
50
- query.refetch();
51
- }
39
+ onClose();
40
+ },
41
+ });
42
+
43
+ flow.start();
44
+ onDestroy(flow.stop);
52
45
 
53
- function onCopy() {
54
- navigator.clipboard.writeText(query.data!.userCode);
46
+ function onBackendCreate(event: SocketEvent<{ backend: BackendDto }>) {
47
+ onBackendCreateHandler(event);
55
48
  }
56
49
  </script>
57
50
 
58
- <OnEvents {onBackendCreate} {onDeviceFlowFailure} />
51
+ <OnEvents {onBackendCreate} />
59
52
 
60
53
  <Modal title="Logging into FUTO Backups" icon={false} {onClose}>
61
54
  <ModalBody>
62
- <Suspense {query}>
63
- <VStack>
64
- <Text>You may be asked or shown the following code:</Text>
65
- <Stack direction="row" align="center">
66
- <Code class="text-3xl select-all">{query.data!.userCode}</Code>
67
- <IconButton
68
- color="secondary"
69
- variant="outline"
70
- icon={mdiContentCopy}
71
- onclick={onCopy}
72
- aria-label="Copy code"
73
- />
74
- </Stack>
75
-
76
- <HStack class="mt-4">
77
- <LoadingSpinner />
78
- <Text>Waiting for you to confirm login...</Text>
79
- </HStack>
80
- </VStack>
81
- </Suspense>
55
+ {#if flow.state.userCode}
56
+ <DeviceFlowCode {flow} />
57
+ {:else if flow.state.error}
58
+ <Text color="danger">{flow.state.error}</Text>
59
+ {:else}
60
+ <LoadingSpinner />
61
+ {/if}
82
62
  </ModalBody>
83
63
  <ModalFooter>
84
64
  <HStack fullWidth>
85
65
  <Button shape="round" color="secondary" fullWidth onclick={onClose}>
86
66
  Cancel
87
67
  </Button>
88
- <Button
89
- shape="round"
90
- fullWidth
91
- onclick={onRetry}
92
- disabled={query.isFetching}>Try again</Button
93
- >
68
+ <DeviceFlowAction {flow} />
94
69
  </HStack>
95
70
  </ModalFooter>
96
71
  </Modal>
@@ -9,9 +9,10 @@
9
9
 
10
10
  type Props = {
11
11
  repository: LocalRepositoryDto;
12
+ local?: boolean
12
13
  };
13
14
 
14
- const { repository }: Props = $props();
15
+ const { repository, local }: Props = $props();
15
16
 
16
17
  const BackendNames = {
17
18
  yucca: "FUTO Backups",
@@ -21,14 +22,29 @@
21
22
 
22
23
  const outcome = $derived(getBackupOutcome(repository.metrics));
23
24
 
24
- const { BackupNow, Snapshots, History, Configure, Import, MetricsHistory } =
25
- $derived(getRepositoryActions(repository));
25
+ const {
26
+ BackupNow,
27
+ Snapshots,
28
+ History,
29
+ Configure,
30
+ Import,
31
+ MetricsHistory,
32
+ Delete,
33
+ } = $derived(getRepositoryActions(repository, local));
26
34
  </script>
27
35
 
28
36
  <StackListItem
29
37
  title={repository.name}
30
38
  color={outcome === "failed" ? "danger" : "primary"}
31
- actions={[BackupNow, Snapshots, History, Configure, Import, MetricsHistory]}
39
+ actions={[
40
+ BackupNow,
41
+ Snapshots,
42
+ History,
43
+ Configure,
44
+ Import,
45
+ MetricsHistory,
46
+ Delete,
47
+ ]}
32
48
  >
33
49
  {#snippet icon()}
34
50
  <Icon icon={mdiArchiveOutline} />
@@ -1,6 +1,7 @@
1
1
  import type { LocalRepositoryDto } from "../../fetch-client";
2
2
  type Props = {
3
3
  repository: LocalRepositoryDto;
4
+ local?: boolean;
4
5
  };
5
6
  declare const BackupItem: import("svelte").Component<Props, {}, "">;
6
7
  type BackupItem = ReturnType<typeof BackupItem>;
@@ -46,7 +46,7 @@
46
46
 
47
47
  <Suspense {query}>
48
48
  {#each localRepositories as repository (repository.id)}
49
- <BackupItem {repository} />
49
+ <BackupItem {repository} {local} />
50
50
  {/each}
51
51
 
52
52
  {#if localRepositories.length === 0}
@@ -4,15 +4,12 @@
4
4
  import { type LocalRepositoryDto } from "../../../fetch-client";
5
5
  import { options } from "../../../options";
6
6
  import {
7
- useRemoveRepository,
8
7
  useUpdateRepository,
9
8
  } from "../../../services/repository.service";
10
9
  import {
11
- Button,
12
10
  Field,
13
11
  FormModal,
14
12
  Input,
15
- modalManager,
16
13
  Stack,
17
14
  } from "@immich/ui";
18
15
  import { SvelteSet } from "svelte/reactivity";
@@ -32,7 +29,6 @@
32
29
  const local = $derived(typeof repository.configuration === "object");
33
30
 
34
31
  const updateMutation = useUpdateRepository();
35
- const removeMutation = useRemoveRepository();
36
32
 
37
33
  const onSubmit = () =>
38
34
  updateMutation.mutate(
@@ -40,30 +36,12 @@
40
36
  { onSuccess: () => onClose() },
41
37
  );
42
38
 
43
- const onRemove = async () => {
44
- const confirm = await modalManager.showDialog({
45
- confirmText: local ? "Remove" : "Delete",
46
- title: local ? "Remove Repository" : "Delete Repository",
47
- prompt: local
48
- ? "Repository will be removed locally."
49
- : "This repository will be removed.",
50
- });
51
-
52
- if (!confirm) return;
53
-
54
- removeMutation.mutate(
55
- { id: repository.id, local },
56
- { onSuccess: () => onClose() },
57
- );
58
- };
59
-
60
39
  const { advanced } = options;
61
40
  </script>
62
41
 
63
42
  <FormModal
64
43
  disabled={name.length === 0 ||
65
- updateMutation.isPending ||
66
- removeMutation.isPending}
44
+ updateMutation.isPending}
67
45
  title={`Configure ${name}`}
68
46
  size="large"
69
47
  {onSubmit}
@@ -89,10 +67,6 @@
89
67
  </PathListField>
90
68
  {/if}
91
69
 
92
- <Button color="danger" loading={removeMutation.isPending} onclick={onRemove}
93
- >Remove Repository from This Device</Button
94
- >
95
-
96
70
  {#if advanced}
97
71
  <BackendsList {repository} />
98
72
  {/if}
@@ -57,6 +57,7 @@
57
57
  <Suspense {query}>
58
58
  {#each sortedRepositories ?? [] as repository (repository.id)}
59
59
  {@const accessible = repository.snapshots !== undefined}
60
+ {@const snapshots = repository.snapshots ?? []}
60
61
 
61
62
  <StackListOption
62
63
  title={repository.name}
@@ -65,10 +66,8 @@
65
66
  >
66
67
  {#if !accessible}
67
68
  Can't access, is your recovery key correct?
68
- {:else if repository.snapshots.length}
69
- Last backup: {new Date(
70
- repository.snapshots[0].time,
71
- ).toLocaleDateString()}
69
+ {:else if snapshots.length}
70
+ Last backup: {new Date(snapshots[0].time).toLocaleDateString()}
72
71
  {:else}
73
72
  No backups yet
74
73
  {/if}
@@ -9,6 +9,7 @@
9
9
  useIntegrations,
10
10
  } from "../../../../services/integrations.service";
11
11
  import {
12
+ handleDisableWormRepository,
12
13
  useRepositories,
13
14
  useRepositoryEventHandler,
14
15
  } from "../../../../services/repository.service";
@@ -85,7 +86,6 @@
85
86
  }
86
87
 
87
88
  name = repository.name;
88
- worm = repository.worm;
89
89
 
90
90
  const policy = repository.configuration?.retentionPolicy ?? null;
91
91
  retentionKey =
@@ -153,17 +153,13 @@
153
153
  <BackendsList {repository} />
154
154
 
155
155
  {#if repository?.worm}
156
- <Field
157
- label="Write-only"
158
- description="Once written, backups can't be removed. This can't be turned off again."
159
- readOnly // @immich/ui forces enabled={false} when disabled={true}
160
- >
161
- <Switch checked class="cursor-not-allowed opacity-38" />
162
- </Field>
156
+ <Button onclick={() => handleDisableWormRepository(repository.id, true)}>
157
+ Disable write-only
158
+ </Button>
163
159
  {:else}
164
160
  <Field
165
161
  label="Write-only"
166
- description="Once written, backups can't be removed. This can't be turned off again."
162
+ description="Once written, backups can't be removed."
167
163
  >
168
164
  <Switch bind:checked={worm} />
169
165
  </Field>
@@ -4,6 +4,7 @@
4
4
  import { LoadingSpinner } from "@immich/ui";
5
5
  import { onMount, type Snippet } from "svelte";
6
6
  import OnboardingBootstrapError from "./OnboardingBootstrapError.svelte";
7
+ import OnboardingLogin from "./OnboardingLogin.svelte";
7
8
  import SampleOnboarding from "./SampleOnboarding.svelte";
8
9
 
9
10
  type Props = {
@@ -23,6 +24,8 @@
23
24
  function onSkip() {
24
25
  onboarding = {
25
26
  status: "ready",
27
+ requiresAuthentication: false,
28
+ isAuthenticated: true,
26
29
  hasTelemetry: "full",
27
30
  hasBackend: true,
28
31
  hasOnboardedKey: true,
@@ -37,6 +40,10 @@
37
40
  <LoadingSpinner />
38
41
  {:else if onboarding.status === "error"}
39
42
  <OnboardingBootstrapError error={onboarding.error} onQuit={onExit} />
43
+ {:else if onboarding.requiresAuthentication && !onboarding.isAuthenticated}
44
+ <OnboardingLogin
45
+ onAuthenticated={() => (onboarding!.isAuthenticated = true)}
46
+ />
40
47
  {:else if onboarding.hasTelemetry === "none" || !(onboarding.hasBackend && onboarding.hasOnboardedKey)}
41
48
  <SampleOnboarding
42
49
  status={onboarding}
@@ -0,0 +1,64 @@
1
+ <script lang="ts">
2
+ import DeviceFlowAction from "../util/DeviceFlowAction.svelte";
3
+ import DeviceFlowCode from "../util/DeviceFlowCode.svelte";
4
+ import { createDeviceFlow } from "../../services/deviceFlow.service.svelte";
5
+ import { useCreateSession } from "../../services/session.service";
6
+ import {
7
+ Button,
8
+ HStack,
9
+ Modal,
10
+ ModalBody,
11
+ ModalFooter,
12
+ Stack,
13
+ Text,
14
+ } from "@immich/ui";
15
+ import { onDestroy } from "svelte";
16
+
17
+ type Props = {
18
+ onAuthenticated: () => void;
19
+ };
20
+
21
+ const { onAuthenticated }: Props = $props();
22
+
23
+ const session = useCreateSession();
24
+
25
+ const flow = createDeviceFlow("session", {
26
+ createSession: (token) => session.mutateAsync(token),
27
+ onComplete: () => onAuthenticated(),
28
+ });
29
+
30
+ onDestroy(flow.stop);
31
+ </script>
32
+
33
+ <Modal title="Log in to FUTO Backups" icon={false} onClose={() => {}}>
34
+ <ModalBody>
35
+ {#if flow.state.userCode}
36
+ <DeviceFlowCode {flow} />
37
+ {:else}
38
+ <Stack gap={4}>
39
+ <Text>
40
+ This instance is connected to a FUTO Backups account. Log in with that
41
+ account to manage it.
42
+ </Text>
43
+
44
+ {#if flow.state.error}
45
+ <Text color="danger">{flow.state.error}</Text>
46
+ {/if}
47
+ </Stack>
48
+ {/if}
49
+ </ModalBody>
50
+ <ModalFooter>
51
+ <HStack fullWidth>
52
+ {#if flow.state.userCode}
53
+ <DeviceFlowAction {flow} />
54
+ {:else}
55
+ <Button
56
+ shape="round"
57
+ fullWidth
58
+ loading={flow.state.pending}
59
+ onclick={flow.start}>Log in with FUTO</Button
60
+ >
61
+ {/if}
62
+ </HStack>
63
+ </ModalFooter>
64
+ </Modal>
@@ -0,0 +1,6 @@
1
+ type Props = {
2
+ onAuthenticated: () => void;
3
+ };
4
+ declare const OnboardingLogin: import("svelte").Component<Props, {}, "">;
5
+ type OnboardingLogin = ReturnType<typeof OnboardingLogin>;
6
+ export default OnboardingLogin;
@@ -28,6 +28,8 @@
28
28
  const { onBack, onFinish, repository }: Props = $props();
29
29
 
30
30
  let selectedSnapshot: SnapshotDto | undefined = $state();
31
+
32
+ const snapshots = $derived(repository.snapshots ?? []);
31
33
  </script>
32
34
 
33
35
  {#if selectedSnapshot}
@@ -45,7 +47,7 @@
45
47
  >
46
48
  <ModalBody>
47
49
  <StackList>
48
- {#each repository.snapshots.toSorted((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()) as snapshot (snapshot.id)}
50
+ {#each snapshots.toSorted((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()) as snapshot (snapshot.id)}
49
51
  <HStack gap={2} class="px-4 py-3">
50
52
  <Stack gap={0} class="grow min-w-0">
51
53
  <HStack
@@ -67,7 +69,7 @@
67
69
  </Button>
68
70
  </HStack>
69
71
  {/each}
70
- {#if repository.snapshots.length === 0}
72
+ {#if snapshots.length === 0}
71
73
  <Text class="text-center py-6" color="muted">
72
74
  No snapshots in this repository.
73
75
  </Text>
@@ -0,0 +1,21 @@
1
+ <script lang="ts">
2
+ import type { DeviceFlow } from "../../services/deviceFlow.service.svelte";
3
+ import { Button } from "@immich/ui";
4
+
5
+ type Props = {
6
+ flow: DeviceFlow;
7
+ };
8
+
9
+ const { flow }: Props = $props();
10
+ </script>
11
+
12
+ {#if flow.state.opened}
13
+ <Button shape="round" fullWidth onclick={flow.start}>Try again</Button>
14
+ {:else}
15
+ <Button
16
+ shape="round"
17
+ fullWidth
18
+ disabled={!flow.state.verificationUri}
19
+ onclick={flow.open}>Continue to login</Button
20
+ >
21
+ {/if}
@@ -0,0 +1,7 @@
1
+ import type { DeviceFlow } from "../../services/deviceFlow.service.svelte";
2
+ type Props = {
3
+ flow: DeviceFlow;
4
+ };
5
+ declare const DeviceFlowAction: import("svelte").Component<Props, {}, "">;
6
+ type DeviceFlowAction = ReturnType<typeof DeviceFlowAction>;
7
+ export default DeviceFlowAction;
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import type { DeviceFlow } from "../../services/deviceFlow.service.svelte";
3
+ import { Code, HStack, IconButton, LoadingSpinner, Stack, Text } from "@immich/ui";
4
+ import { mdiContentCopy } from "@mdi/js";
5
+
6
+ type Props = {
7
+ flow: DeviceFlow;
8
+ };
9
+
10
+ const { flow }: Props = $props();
11
+
12
+ function onCopy() {
13
+ navigator.clipboard.writeText(flow.state.userCode!);
14
+ }
15
+ </script>
16
+
17
+ <Stack gap={4}>
18
+ <Text>You may be asked or shown the following code:</Text>
19
+ <Stack direction="row" align="center">
20
+ <Code class="text-3xl select-all">{flow.state.userCode}</Code>
21
+ <IconButton
22
+ color="secondary"
23
+ variant="outline"
24
+ icon={mdiContentCopy}
25
+ onclick={onCopy}
26
+ aria-label="Copy code"
27
+ />
28
+ </Stack>
29
+
30
+ {#if flow.state.opened}
31
+ <HStack>
32
+ <LoadingSpinner />
33
+ <Text>Waiting for you to confirm login...</Text>
34
+ </HStack>
35
+ {/if}
36
+ </Stack>
@@ -0,0 +1,7 @@
1
+ import type { DeviceFlow } from "../../services/deviceFlow.service.svelte";
2
+ type Props = {
3
+ flow: DeviceFlow;
4
+ };
5
+ declare const DeviceFlowCode: import("svelte").Component<Props, {}, "">;
6
+ type DeviceFlowCode = ReturnType<typeof DeviceFlowCode>;
7
+ export default DeviceFlowCode;
@@ -9,9 +9,28 @@ export declare const defaults: Oazapfts.Defaults<Oazapfts.CustomHeaders>;
9
9
  export declare const servers: {
10
10
  server1: string;
11
11
  };
12
- export type DeviceFlowResponseDto = {
13
- userCode: string;
14
- verificationUri: string;
12
+ export type DeviceFlowEventType = "START" | "SUCCESS" | "FAILURE";
13
+ export type DeviceFlowFailureReason = "NOT_CONNECTED" | "DEVICE_FLOW_FAILED" | "WRONG_ACCOUNT" | "UNKNOWN";
14
+ export type DeviceFlowEventDto = {
15
+ "type": DeviceFlowEventType;
16
+ userCode?: string;
17
+ verificationUri?: string;
18
+ token?: string;
19
+ backendId?: string;
20
+ reason?: DeviceFlowFailureReason;
21
+ };
22
+ export type CreateSessionRequestDto = {
23
+ token: string;
24
+ };
25
+ export type TicketAction = "repository.delete" | "repository.disable-worm";
26
+ export type TicketCreateRequestDto = {
27
+ action: TicketAction;
28
+ /** Repository the ticket is bound to */
29
+ repositoryId: string;
30
+ };
31
+ export type TicketCreateResponseDto = {
32
+ /** Identity provider URL the browser must be sent to */
33
+ redirectTo: string;
15
34
  };
16
35
  export type BackendType = "yucca" | "local" | "s3";
17
36
  export type BackendDto = {
@@ -94,6 +113,8 @@ export type OnboardingStatusResponseDto = {
94
113
  status: BootstrapStatus;
95
114
  error?: string;
96
115
  hasTelemetry: TelemetryLevel;
116
+ requiresAuthentication: boolean;
117
+ isAuthenticated: boolean;
97
118
  hasOnboardedKey: boolean;
98
119
  hasBackend: boolean;
99
120
  hasBackup: boolean;
@@ -115,7 +136,7 @@ export type RepositoryCreateRequestDto = {
115
136
  };
116
137
  export type TaskStatus = "incomplete" | "complete" | "warn" | "failed";
117
138
  export type RepositoryMetricsDto = {
118
- lastBackup?: string;
139
+ lastBackup?: string | null;
119
140
  lastBackupStatus?: TaskStatus;
120
141
  lastBackupDuration?: number;
121
142
  sizeBytes: number;
@@ -123,7 +144,7 @@ export type RepositoryMetricsDto = {
123
144
  export type RepositoryMeterDto = {
124
145
  sizeBytes: number;
125
146
  objectCount: number;
126
- lastUpdated?: string;
147
+ lastUpdated?: string | null;
127
148
  };
128
149
  export type RepositoryBackendDto = {
129
150
  id: string;
@@ -180,7 +201,7 @@ export type InspectedLocalRepositoryDto = {
180
201
  meter?: RepositoryMeterDto;
181
202
  backends?: RepositoryBackendsDto;
182
203
  configuration?: RepositoryConfigurationDto;
183
- snapshots: SnapshotDto[];
204
+ snapshots?: SnapshotDto[];
184
205
  };
185
206
  export type RepositoryInspectResponseDto = {
186
207
  repositories: InspectedLocalRepositoryDto[];
@@ -209,7 +230,7 @@ export type RunDto = {
209
230
  id: string;
210
231
  repositoryId: string;
211
232
  start: string;
212
- end: string;
233
+ end?: string;
213
234
  logFilePath: string;
214
235
  status: RunStatus;
215
236
  "type": RunType;
@@ -274,7 +295,10 @@ export type ScheduleUpdateRequestDto = {
274
295
  export type ScheduleUpdateResponseDto = {
275
296
  schedule: ScheduleDto;
276
297
  };
277
- export declare function oidcDeviceFlow(opts?: Oazapfts.RequestOpts): Promise<DeviceFlowResponseDto>;
298
+ export declare function connectDeviceFlow(opts?: Oazapfts.RequestOpts): Promise<DeviceFlowEventDto>;
299
+ export declare function sessionDeviceFlow(opts?: Oazapfts.RequestOpts): Promise<DeviceFlowEventDto>;
300
+ export declare function createSession(createSessionRequestDto: CreateSessionRequestDto, opts?: Oazapfts.RequestOpts): Promise<never>;
301
+ export declare function createTicket(ticketCreateRequestDto: TicketCreateRequestDto, opts?: Oazapfts.RequestOpts): Promise<TicketCreateResponseDto>;
278
302
  export declare function getBackends(opts?: Oazapfts.RequestOpts): Promise<BackendsResponseDto>;
279
303
  export declare function createLocalBackend(createLocalBackendRequestDto: CreateLocalBackendRequestDto, opts?: Oazapfts.RequestOpts): Promise<BackendResponseDto>;
280
304
  export declare function resetOrchestrator(opts?: Oazapfts.RequestOpts): Promise<never>;
@@ -14,11 +14,30 @@ const oazapfts = Oazapfts.runtime(defaults);
14
14
  export const servers = {
15
15
  server1: "http://localhost:22676"
16
16
  };
17
- export function oidcDeviceFlow(opts) {
17
+ export function connectDeviceFlow(opts) {
18
18
  return oazapfts.ok(oazapfts.fetchJson("/api/yucca/auth/oidc/device", {
19
19
  ...opts
20
20
  }));
21
21
  }
22
+ export function sessionDeviceFlow(opts) {
23
+ return oazapfts.ok(oazapfts.fetchJson("/api/yucca/auth/session/device", {
24
+ ...opts
25
+ }));
26
+ }
27
+ export function createSession(createSessionRequestDto, opts) {
28
+ return oazapfts.ok(oazapfts.fetchText("/api/yucca/auth/session", oazapfts.json({
29
+ ...opts,
30
+ method: "POST",
31
+ body: createSessionRequestDto
32
+ })));
33
+ }
34
+ export function createTicket(ticketCreateRequestDto, opts) {
35
+ return oazapfts.ok(oazapfts.fetchJson("/api/yucca/auth/ticket", oazapfts.json({
36
+ ...opts,
37
+ method: "POST",
38
+ body: ticketCreateRequestDto
39
+ })));
40
+ }
22
41
  export function getBackends(opts) {
23
42
  return oazapfts.ok(oazapfts.fetchJson("/api/yucca/backend", {
24
43
  ...opts
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { default as Suspense } from './components/util/Suspense.svelte';
1
2
  export { default as BackendsList } from './components/backends/BackendsList.svelte';
2
3
  export { default as BackupsList } from './components/backups/BackupsList.svelte';
3
4
  export { default as ViewStatusModal } from './components/backups/dialogs/ViewStatusModal.svelte';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { default as Suspense } from './components/util/Suspense.svelte';
1
2
  export { default as BackendsList } from './components/backends/BackendsList.svelte';
2
3
  export { default as BackupsList } from './components/backups/BackupsList.svelte';
3
4
  export { default as ViewStatusModal } from './components/backups/dialogs/ViewStatusModal.svelte';
@@ -3,10 +3,8 @@ import { type BackendDto, type CreateLocalBackendRequestDto, type LocalRepositor
3
3
  import { type ActionItem } from '@immich/ui';
4
4
  export declare const backendKeys: {
5
5
  all: readonly ["backends"];
6
- deviceFlow: (uid: string) => readonly ["deviceFlow", string];
7
6
  };
8
7
  export declare const useBackends: () => import("@tanstack/svelte-query").CreateQueryResult<BackendDto[], Error>;
9
- export declare const useDeviceFlow: (uid: string) => import("@tanstack/svelte-query").CreateQueryResult<import("../fetch-client").DeviceFlowResponseDto, Error>;
10
8
  export declare const useBackendEventHandler: () => {
11
9
  onBackendCreate(event: SocketEvent<{
12
10
  backend: BackendDto;
@@ -2,7 +2,7 @@ import CreateLocalBackend from '../components/backends/dialogs/CreateLocalBacken
2
2
  import OAuthDeviceFlow from '../components/backends/dialogs/OAuthDeviceFlowModal.svelte';
3
3
  import ReconfigureRepositoryBackendModal from '../components/backups/dialogs/ReconfigureRepositoryBackendModal.svelte';
4
4
  import { SocketEvent } from '../events';
5
- import { createLocalBackend, getBackends, oidcDeviceFlow, } from '../fetch-client';
5
+ import { createLocalBackend, getBackends, } from '../fetch-client';
6
6
  import { queryClient } from '../query-client';
7
7
  import { handleError } from '../utils/handle-error';
8
8
  import { modalManager } from '@immich/ui';
@@ -10,25 +10,11 @@ import { mdiLogin } from '@mdi/js';
10
10
  import { createMutation, createQuery } from '@tanstack/svelte-query';
11
11
  export const backendKeys = {
12
12
  all: ['backends'],
13
- deviceFlow: (uid) => ['deviceFlow', uid],
14
13
  };
15
14
  export const useBackends = () => createQuery(() => ({
16
15
  queryKey: backendKeys.all,
17
16
  queryFn: () => getBackends().then(({ backends }) => backends),
18
17
  }), () => queryClient);
19
- export const useDeviceFlow = (uid) => createQuery(() => ({
20
- queryKey: backendKeys.deviceFlow(uid),
21
- queryFn: async () => {
22
- const response = await oidcDeviceFlow();
23
- window.open(response.verificationUri, '_blank');
24
- return response;
25
- },
26
- gcTime: Infinity,
27
- retry: 0,
28
- refetchOnReconnect: false,
29
- refetchOnWindowFocus: false,
30
- refetchOnMount: true,
31
- }), () => queryClient);
32
18
  export const useBackendEventHandler = () => {
33
19
  return {
34
20
  onBackendCreate(event) {
@@ -0,0 +1,19 @@
1
+ import type { DeviceFlowEventDto } from '../fetch-client';
2
+ import { type DeviceFlowKind } from './session.service';
3
+ export declare function createDeviceFlow(kind: DeviceFlowKind, handlers: {
4
+ createSession: (token: string) => Promise<unknown>;
5
+ onComplete: (event: DeviceFlowEventDto) => void;
6
+ onFailure?: (message: string) => void;
7
+ }): {
8
+ state: {
9
+ userCode: string | undefined;
10
+ verificationUri: string | undefined;
11
+ error: string | undefined;
12
+ pending: boolean;
13
+ opened: boolean;
14
+ };
15
+ start: () => void;
16
+ stop: () => void;
17
+ open: () => void;
18
+ };
19
+ export type DeviceFlow = ReturnType<typeof createDeviceFlow>;
@@ -0,0 +1,70 @@
1
+ import { startDeviceFlow, } from './session.service';
2
+ const failures = {
3
+ NOT_CONNECTED: 'This instance is not connected to a FUTO Backups account yet.',
4
+ DEVICE_FLOW_FAILED: 'Login was cancelled or timed out.',
5
+ WRONG_ACCOUNT: 'That account does not own this instance. Log in with the account it was connected with.',
6
+ UNKNOWN: 'Could not reach FUTO Backups. Check your connection.',
7
+ };
8
+ export function createDeviceFlow(kind, handlers) {
9
+ const state = $state({
10
+ userCode: undefined,
11
+ verificationUri: undefined,
12
+ error: undefined,
13
+ pending: false,
14
+ opened: false,
15
+ });
16
+ let close;
17
+ const stop = () => {
18
+ close?.();
19
+ close = undefined;
20
+ };
21
+ const complete = async (event) => {
22
+ if (event.token) {
23
+ await handlers.createSession(event.token);
24
+ }
25
+ state.pending = false;
26
+ handlers.onComplete(event);
27
+ };
28
+ const start = () => {
29
+ stop();
30
+ state.userCode = undefined;
31
+ state.error = undefined;
32
+ state.pending = true;
33
+ state.opened = false;
34
+ close = startDeviceFlow(kind, (event) => {
35
+ switch (event.type) {
36
+ case 'START': {
37
+ state.userCode = event.userCode;
38
+ state.verificationUri = event.verificationUri;
39
+ break;
40
+ }
41
+ case 'SUCCESS': {
42
+ stop();
43
+ void complete(event);
44
+ break;
45
+ }
46
+ case 'FAILURE': {
47
+ stop();
48
+ state.pending = false;
49
+ state.userCode = undefined;
50
+ state.error = failures[event.reason ?? 'UNKNOWN'];
51
+ handlers.onFailure?.(state.error);
52
+ break;
53
+ }
54
+ }
55
+ });
56
+ };
57
+ const open = () => {
58
+ if (!state.verificationUri) {
59
+ return;
60
+ }
61
+ state.opened = true;
62
+ window.open(state.verificationUri, 'futo-backups-device');
63
+ };
64
+ return {
65
+ state,
66
+ start,
67
+ stop,
68
+ open,
69
+ };
70
+ }
@@ -36,17 +36,16 @@ export declare const useReconfigureRepositoryPrimaryBackend: () => import("@tans
36
36
  id: string;
37
37
  backendId: string;
38
38
  }, unknown>;
39
- export declare const useRemoveRepository: () => import("@tanstack/svelte-query").CreateMutationResult<never, Error, {
40
- id: string;
41
- local?: boolean;
42
- }, unknown>;
43
39
  export declare const handleCreateBackup: (id: string) => Promise<sdk.LogResponseDto>;
44
40
  export declare const handlePruneRepository: (id: string) => Promise<sdk.LogResponseDto>;
45
- export declare const getRepositoryActions: (repository: LocalRepositoryDto) => {
41
+ export declare const handleDeleteRepository: (repositoryId: string, local?: boolean) => Promise<void>;
42
+ export declare const handleDisableWormRepository: (repositoryId: string, local?: boolean) => Promise<void>;
43
+ export declare const getRepositoryActions: (repository: LocalRepositoryDto, local?: boolean) => {
46
44
  BackupNow: ActionItem;
47
45
  Snapshots: ActionItem;
48
46
  History: ActionItem;
49
47
  Configure: ActionItem;
50
48
  Import: ActionItem;
51
49
  MetricsHistory: ActionItem;
50
+ Delete: ActionItem;
52
51
  };
@@ -6,12 +6,13 @@ import MetricsHistoryModal from '../components/backups/metrics-history/MetricsHi
6
6
  import RunHistoryModal from '../components/backups/run-history/RunHistoryModal.svelte';
7
7
  import SnapshotsListModal from '../components/backups/snapshots-list/SnapshotsListModal.svelte';
8
8
  import { SocketEvent } from '../events';
9
- import { deleteRepository, inspectRepositories, reconfigureRepositoryPrimaryBackend, updateRepository, } from '../fetch-client';
9
+ import { inspectRepositories, reconfigureRepositoryPrimaryBackend, updateRepository, } from '../fetch-client';
10
10
  import { getProvider } from '../providers';
11
11
  import { queryClient } from '../query-client';
12
12
  import { handleError } from '../utils/handle-error';
13
+ import { createTicket } from '@futo-org/backups-api-client';
13
14
  import { modalManager, toastManager } from '@immich/ui';
14
- import { mdiCog, mdiFormatListBulletedType, mdiHistory, mdiImport, mdiListStatus, mdiPlay, } from '@mdi/js';
15
+ import { mdiCog, mdiFormatListBulletedType, mdiHistory, mdiImport, mdiListStatus, mdiPlay, mdiTrashCan, } from '@mdi/js';
15
16
  import { createMutation, createQuery } from '@tanstack/svelte-query';
16
17
  export const repositoryKeys = {
17
18
  all: ['repositories'],
@@ -76,10 +77,6 @@ export const useReconfigureRepositoryPrimaryBackend = () => createMutation(() =>
76
77
  mutationFn: ({ id, backendId }) => reconfigureRepositoryPrimaryBackend(id, { backendId }),
77
78
  onError: (error) => handleError(error, 'Failed to reconfigure backend'),
78
79
  }), () => queryClient);
79
- export const useRemoveRepository = () => createMutation(() => ({
80
- mutationFn: ({ id, local = false }) => local ? sdk.deleteRepository(id) : deleteRepository(id),
81
- onError: (error) => handleError(error, 'Failed to delete repository'),
82
- }), () => queryClient);
83
80
  export const handleCreateBackup = async (id) => {
84
81
  try {
85
82
  toastManager.info('Started backup');
@@ -110,7 +107,35 @@ export const handlePruneRepository = async (id) => {
110
107
  throw error;
111
108
  }
112
109
  };
113
- export const getRepositoryActions = (repository) => {
110
+ export const handleDeleteRepository = async (repositoryId, local) => {
111
+ try {
112
+ const action = local ? sdk.createTicket : createTicket;
113
+ const { redirectTo } = await action({
114
+ action: 'repository.delete',
115
+ repositoryId,
116
+ });
117
+ window.open(redirectTo, '_blank');
118
+ }
119
+ catch (error) {
120
+ handleError(error, 'Failed to start deletion');
121
+ throw error;
122
+ }
123
+ };
124
+ export const handleDisableWormRepository = async (repositoryId, local) => {
125
+ try {
126
+ const action = local ? sdk.createTicket : createTicket;
127
+ const { redirectTo } = await action({
128
+ action: 'repository.disable-worm',
129
+ repositoryId,
130
+ });
131
+ window.open(redirectTo, '_blank');
132
+ }
133
+ catch (error) {
134
+ handleError(error, 'Failed to start disable write-only process');
135
+ throw error;
136
+ }
137
+ };
138
+ export const getRepositoryActions = (repository, local) => {
114
139
  const online = Boolean(repository.configuration && repository.backends?.primary.online);
115
140
  const configured = Boolean(repository.configuration);
116
141
  const BackupNow = {
@@ -151,5 +176,19 @@ export const getRepositoryActions = (repository) => {
151
176
  onAction: () => void modalManager.open(MetricsHistoryModal, { repository }),
152
177
  $if: () => !repository.backends,
153
178
  };
154
- return { BackupNow, Snapshots, History, Configure, Import, MetricsHistory };
179
+ const Delete = {
180
+ title: 'Delete repository',
181
+ icon: mdiTrashCan,
182
+ onAction: () => void handleDeleteRepository(repository.id, local),
183
+ $if: () => !repository.backends || repository.backends.primary.type === 'yucca',
184
+ };
185
+ return {
186
+ BackupNow,
187
+ Snapshots,
188
+ History,
189
+ Configure,
190
+ Import,
191
+ MetricsHistory,
192
+ Delete,
193
+ };
155
194
  };
@@ -0,0 +1,4 @@
1
+ import { type DeviceFlowEventDto } from '../fetch-client';
2
+ export type DeviceFlowKind = 'oidc' | 'session';
3
+ export declare const startDeviceFlow: (kind: DeviceFlowKind, onEvent: (event: DeviceFlowEventDto) => void) => () => void;
4
+ export declare const useCreateSession: () => import("@tanstack/svelte-query").CreateMutationResult<never, Error, string, unknown>;
@@ -0,0 +1,19 @@
1
+ import { createSession, defaults, } from '../fetch-client';
2
+ import { queryClient } from '../query-client';
3
+ import { handleError } from '../utils/handle-error';
4
+ import { createMutation } from '@tanstack/svelte-query';
5
+ export const startDeviceFlow = (kind, onEvent) => {
6
+ const source = new EventSource(`${defaults.baseUrl.replace(/\/$/, '')}/api/yucca/auth/${kind}/device`, { withCredentials: true });
7
+ source.addEventListener('message', (event) => {
8
+ onEvent(JSON.parse(event.data));
9
+ });
10
+ source.addEventListener('error', () => {
11
+ onEvent({ type: 'FAILURE', reason: 'UNKNOWN' });
12
+ source.close();
13
+ });
14
+ return () => source.close();
15
+ };
16
+ export const useCreateSession = () => createMutation(() => ({
17
+ mutationFn: (token) => createSession({ token }),
18
+ onError: (error) => handleError(error, 'Failed to log in'),
19
+ }), () => queryClient);
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@futo-org/backups-orchestrator-ui",
3
3
  "repository": "https://github.com/immich-app/yucca",
4
4
  "description": "Backups orchestrator (UI library)",
5
- "version": "0.38.0",
5
+ "version": "0.40.0",
6
6
  "license": "Source First License 1.1",
7
7
  "files": [
8
8
  "dist",
@@ -64,7 +64,7 @@
64
64
  "lodash.debounce": "^4.0.8",
65
65
  "luxon": "^3.7.2",
66
66
  "socket.io-client": "^4.8.3",
67
- "@futo-org/backups-api-client": "^0.38.0"
67
+ "@futo-org/backups-api-client": "^0.40.0"
68
68
  },
69
69
  "scripts": {
70
70
  "dev": "vite dev --port 5174",