nutstore-webdav-secret-cli 0.1.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.
- package/README.md +265 -0
- package/package.json +54 -0
- package/scripts/build.ts +56 -0
- package/src/App.tsx +57 -0
- package/src/atom/auth.ts +55 -0
- package/src/atom/platform.ts +10 -0
- package/src/atom/secrets.ts +94 -0
- package/src/authMock.ts +11 -0
- package/src/cli.tsx +6 -0
- package/src/components/auth-gate.tsx +205 -0
- package/src/components/footer.tsx +37 -0
- package/src/components/header.tsx +39 -0
- package/src/components/nutstore-logo.tsx +56 -0
- package/src/components/secret-detail.tsx +198 -0
- package/src/components/secret-list.tsx +78 -0
- package/src/components/secrets-page.tsx +252 -0
- package/src/constants.ts +1 -0
- package/src/effect/add-secret.ts +94 -0
- package/src/effect/auth.ts +46 -0
- package/src/effect/delete-secret.ts +62 -0
- package/src/effect/list-secrets.ts +160 -0
- package/src/hooks/useThemeMode.ts +26 -0
- package/src/index.tsx +1 -0
- package/src/mockSecrets.ts +39 -0
- package/src/opentui-jsx.d.ts +94 -0
- package/src/theme.ts +49 -0
- package/src/types.ts +7 -0
- package/src/utils.ts +31 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { useAtomSet } from "@effect/atom-solid";
|
|
2
|
+
import { TextAttributes } from "@opentui/core";
|
|
3
|
+
import { useKeyboard, useRenderer } from "@opentui/solid";
|
|
4
|
+
import { Match, Show, Switch, createSignal } from "solid-js";
|
|
5
|
+
import { saveCookieAtom } from "../atom/auth";
|
|
6
|
+
import type { OpenTUIElement } from "../opentui-jsx";
|
|
7
|
+
import type { Palette } from "../theme";
|
|
8
|
+
import { NutstoreLogo } from "./nutstore-logo";
|
|
9
|
+
|
|
10
|
+
export type AuthView = "choice" | "manual" | "validating";
|
|
11
|
+
|
|
12
|
+
type AuthGateProps = {
|
|
13
|
+
palette: Palette;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function AuthGate(props: AuthGateProps): OpenTUIElement {
|
|
17
|
+
const renderer = useRenderer();
|
|
18
|
+
const saveCookie = useAtomSet(() => saveCookieAtom, { mode: "promise" });
|
|
19
|
+
const [view, setView] = createSignal<AuthView>("choice");
|
|
20
|
+
const [cookieInput, setCookieInput] = createSignal("");
|
|
21
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
22
|
+
|
|
23
|
+
const persistCookie = async (cookie: string) => {
|
|
24
|
+
setView("validating");
|
|
25
|
+
setError(null);
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
await saveCookie(cookie);
|
|
29
|
+
} catch (cause) {
|
|
30
|
+
setView("manual");
|
|
31
|
+
setError(cause instanceof Error ? cause.message : "Cookie validation failed. Paste a Cookie header to continue.");
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const selectManualInput = () => {
|
|
36
|
+
setView("manual");
|
|
37
|
+
setError(null);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const submitChoice = () => {
|
|
41
|
+
selectManualInput();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const submitManualCookie = () => {
|
|
45
|
+
void persistCookie(cookieInput());
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const isSubmitKey = (name: string) => name === "enter" || name === "return";
|
|
49
|
+
|
|
50
|
+
useKeyboard((key) => {
|
|
51
|
+
const name = key.name.toLowerCase();
|
|
52
|
+
|
|
53
|
+
if (name === "q") {
|
|
54
|
+
renderer.destroy();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (view() === "choice" && name === "m") {
|
|
59
|
+
selectManualInput();
|
|
60
|
+
key.preventDefault();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (view() === "choice" && isSubmitKey(name)) {
|
|
65
|
+
submitChoice();
|
|
66
|
+
key.preventDefault();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (view() === "manual" && isSubmitKey(name)) {
|
|
71
|
+
submitManualCookie();
|
|
72
|
+
key.preventDefault();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1} gap={1}>
|
|
78
|
+
<box height={7} flexDirection="row" alignItems="center">
|
|
79
|
+
<box
|
|
80
|
+
width={40}
|
|
81
|
+
marginRight={2}
|
|
82
|
+
alignItems="center"
|
|
83
|
+
justifyContent="center"
|
|
84
|
+
>
|
|
85
|
+
<NutstoreLogo palette={props.palette} />
|
|
86
|
+
</box>
|
|
87
|
+
<box flexDirection="column" flexGrow={1} justifyContent="center">
|
|
88
|
+
<text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
|
|
89
|
+
WebDAV Cookie Setup
|
|
90
|
+
</text>
|
|
91
|
+
<text fg={props.palette.muted} truncate>
|
|
92
|
+
Prepare access before loading app passwords
|
|
93
|
+
</text>
|
|
94
|
+
</box>
|
|
95
|
+
</box>
|
|
96
|
+
|
|
97
|
+
<box
|
|
98
|
+
border
|
|
99
|
+
borderColor={props.palette.border}
|
|
100
|
+
flexDirection="column"
|
|
101
|
+
flexGrow={1}
|
|
102
|
+
justifyContent="center"
|
|
103
|
+
paddingX={3}
|
|
104
|
+
paddingY={1}
|
|
105
|
+
title="Authentication"
|
|
106
|
+
>
|
|
107
|
+
<AuthBody
|
|
108
|
+
cookieInput={cookieInput()}
|
|
109
|
+
error={error()}
|
|
110
|
+
onCookieInput={setCookieInput}
|
|
111
|
+
onManualSubmit={submitManualCookie}
|
|
112
|
+
palette={props.palette}
|
|
113
|
+
view={view()}
|
|
114
|
+
/>
|
|
115
|
+
</box>
|
|
116
|
+
|
|
117
|
+
<box height={1}>
|
|
118
|
+
<text fg={props.palette.muted}>
|
|
119
|
+
Enter continue M manual input Q quit
|
|
120
|
+
</text>
|
|
121
|
+
</box>
|
|
122
|
+
</box>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type AuthBodyProps = {
|
|
127
|
+
cookieInput: string;
|
|
128
|
+
error: string | null;
|
|
129
|
+
onCookieInput: (value: string) => void;
|
|
130
|
+
onManualSubmit: () => void;
|
|
131
|
+
palette: Palette;
|
|
132
|
+
view: AuthView;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
function AuthBody(props: AuthBodyProps): OpenTUIElement {
|
|
136
|
+
return (
|
|
137
|
+
<Switch>
|
|
138
|
+
<Match when={props.view === "validating"}>
|
|
139
|
+
<box flexDirection="column" gap={1}>
|
|
140
|
+
<text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
|
|
141
|
+
Validating cookie...
|
|
142
|
+
</text>
|
|
143
|
+
<text fg={props.palette.muted}>
|
|
144
|
+
Saving cookie and validating local session state.
|
|
145
|
+
</text>
|
|
146
|
+
</box>
|
|
147
|
+
</Match>
|
|
148
|
+
|
|
149
|
+
<Match when={props.view === "manual"}>
|
|
150
|
+
<box flexDirection="column" gap={1}>
|
|
151
|
+
<text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
|
|
152
|
+
Paste Nutstore Cookie
|
|
153
|
+
</text>
|
|
154
|
+
<text fg={props.palette.muted}>
|
|
155
|
+
Paste the full Cookie header, then press Enter.
|
|
156
|
+
</text>
|
|
157
|
+
<input
|
|
158
|
+
cursorColor={props.palette.cursor}
|
|
159
|
+
focused
|
|
160
|
+
focusedTextColor={props.palette.fg}
|
|
161
|
+
onInput={props.onCookieInput}
|
|
162
|
+
onSubmit={props.onManualSubmit}
|
|
163
|
+
placeholder="nutstore_cookie=...; session=..."
|
|
164
|
+
placeholderColor={props.palette.muted}
|
|
165
|
+
textColor={props.palette.fg}
|
|
166
|
+
value={props.cookieInput}
|
|
167
|
+
/>
|
|
168
|
+
<AuthError error={props.error} palette={props.palette} />
|
|
169
|
+
</box>
|
|
170
|
+
</Match>
|
|
171
|
+
|
|
172
|
+
<Match when={props.view === "choice"}>
|
|
173
|
+
<box flexDirection="column" gap={1}>
|
|
174
|
+
<text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
|
|
175
|
+
No stored cookie found
|
|
176
|
+
</text>
|
|
177
|
+
<text fg={props.palette.muted}>
|
|
178
|
+
Manual input is the only available authentication method right now.
|
|
179
|
+
</text>
|
|
180
|
+
<text fg={props.palette.fg}>
|
|
181
|
+
Press Enter to paste a Cookie header manually.
|
|
182
|
+
</text>
|
|
183
|
+
<text fg={props.palette.muted}>
|
|
184
|
+
Automatic browser cookie detection is hidden until the experimental flow is ready.
|
|
185
|
+
</text>
|
|
186
|
+
|
|
187
|
+
<AuthError error={props.error} palette={props.palette} />
|
|
188
|
+
</box>
|
|
189
|
+
</Match>
|
|
190
|
+
</Switch>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function AuthError(props: {
|
|
195
|
+
error: string | null;
|
|
196
|
+
palette: Palette;
|
|
197
|
+
}): OpenTUIElement {
|
|
198
|
+
return (
|
|
199
|
+
<Show when={props.error}>
|
|
200
|
+
<text fg={props.palette.danger}>
|
|
201
|
+
{props.error}
|
|
202
|
+
</text>
|
|
203
|
+
</Show>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import type { OpenTUIElement } from "../opentui-jsx";
|
|
3
|
+
import type { Palette } from "../theme";
|
|
4
|
+
|
|
5
|
+
export function Footer(props: { palette: Palette }): OpenTUIElement {
|
|
6
|
+
return (
|
|
7
|
+
<box height={1} flexDirection="row" flexWrap="wrap" columnGap={1}>
|
|
8
|
+
<FooterHint action="select" keyLabel="Up/Down" palette={props.palette} />
|
|
9
|
+
<FooterHint action="copy secret" keyLabel="Enter" palette={props.palette} />
|
|
10
|
+
<FooterHint action="copy URL" keyLabel="U" palette={props.palette} />
|
|
11
|
+
<FooterHint action="copy account" keyLabel="A" palette={props.palette} />
|
|
12
|
+
<FooterHint action="new secret" keyLabel="N" palette={props.palette} />
|
|
13
|
+
<FooterHint action="delete" keyLabel="D" palette={props.palette} />
|
|
14
|
+
<FooterHint action="refresh" keyLabel="R" palette={props.palette} />
|
|
15
|
+
<FooterHint action="quit" keyLabel="Q" palette={props.palette} />
|
|
16
|
+
</box>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function FooterHint(props: {
|
|
21
|
+
action: string;
|
|
22
|
+
keyLabel: string;
|
|
23
|
+
palette: Palette;
|
|
24
|
+
}): OpenTUIElement {
|
|
25
|
+
return (
|
|
26
|
+
<box flexDirection="row" marginRight={1}>
|
|
27
|
+
<text
|
|
28
|
+
attributes={TextAttributes.BOLD}
|
|
29
|
+
bg={props.palette.selectedBg}
|
|
30
|
+
fg={props.palette.selectedFg}
|
|
31
|
+
>
|
|
32
|
+
{" "}{props.keyLabel}{" "}
|
|
33
|
+
</text>
|
|
34
|
+
<text fg={props.palette.muted}> {props.action}</text>
|
|
35
|
+
</box>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { TextAttributes, type ThemeMode } from "@opentui/core";
|
|
2
|
+
import type { OpenTUIElement } from "../opentui-jsx";
|
|
3
|
+
import type { Palette } from "../theme";
|
|
4
|
+
import { NutstoreLogo } from "./nutstore-logo";
|
|
5
|
+
|
|
6
|
+
type HeaderProps = {
|
|
7
|
+
count: number;
|
|
8
|
+
palette: Palette;
|
|
9
|
+
status: string;
|
|
10
|
+
themeMode: ThemeMode | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function Header(props: HeaderProps): OpenTUIElement {
|
|
14
|
+
return (
|
|
15
|
+
<box height={7} flexDirection="row" alignItems="center">
|
|
16
|
+
<box
|
|
17
|
+
width={40}
|
|
18
|
+
marginRight={2}
|
|
19
|
+
alignItems="center"
|
|
20
|
+
justifyContent="center"
|
|
21
|
+
>
|
|
22
|
+
<NutstoreLogo palette={props.palette} />
|
|
23
|
+
</box>
|
|
24
|
+
<box
|
|
25
|
+
flexDirection="column"
|
|
26
|
+
flexGrow={1}
|
|
27
|
+
justifyContent="center"
|
|
28
|
+
rowGap={1}
|
|
29
|
+
>
|
|
30
|
+
<text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
|
|
31
|
+
WebDAV App Passwords
|
|
32
|
+
</text>
|
|
33
|
+
<text fg={props.palette.muted} truncate>
|
|
34
|
+
{props.count} secrets | {props.themeMode ?? "light"} | {props.status}
|
|
35
|
+
</text>
|
|
36
|
+
</box>
|
|
37
|
+
</box>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import type { OpenTUIElement } from "../opentui-jsx";
|
|
3
|
+
import type { Palette } from "../theme";
|
|
4
|
+
|
|
5
|
+
type NutstoreLogoProps = {
|
|
6
|
+
palette: Palette;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const MARK_LINES = [
|
|
10
|
+
[" ▗▄▄▄ ", "brand"],
|
|
11
|
+
[" ▟████▙ ", "brand"],
|
|
12
|
+
[" ███▀▀ ", "accent"],
|
|
13
|
+
[" ▜████▛ ", "brand"],
|
|
14
|
+
[" ▝▀▀▘ ", "muted"],
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
const WORDMARK_LINES = [
|
|
18
|
+
"NUTSTORE",
|
|
19
|
+
"Personal cloud,",
|
|
20
|
+
"without the noise",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export function NutstoreLogo(props: NutstoreLogoProps): OpenTUIElement {
|
|
24
|
+
return (
|
|
25
|
+
<box alignItems="center" flexDirection="row" gap={2}>
|
|
26
|
+
<box flexDirection="column">
|
|
27
|
+
{MARK_LINES.map(([line, tone]) => (
|
|
28
|
+
<text fg={toneColor(props.palette, tone)}>{line}</text>
|
|
29
|
+
))}
|
|
30
|
+
</box>
|
|
31
|
+
|
|
32
|
+
<box flexDirection="column" justifyContent="center">
|
|
33
|
+
<text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
|
|
34
|
+
{WORDMARK_LINES[0]}
|
|
35
|
+
</text>
|
|
36
|
+
<text fg={props.palette.muted}>{WORDMARK_LINES[1]}</text>
|
|
37
|
+
<text fg={props.palette.muted}>{WORDMARK_LINES[2]}</text>
|
|
38
|
+
</box>
|
|
39
|
+
</box>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toneColor(
|
|
44
|
+
palette: Palette,
|
|
45
|
+
tone: "brand" | "accent" | "muted",
|
|
46
|
+
): string {
|
|
47
|
+
if (tone === "muted") {
|
|
48
|
+
return palette.muted;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (tone === "accent") {
|
|
52
|
+
return palette.brandLogo[3] ?? palette.fg;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return palette.brandLogo[1] ?? palette.fg;
|
|
56
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import { Show } from "solid-js";
|
|
3
|
+
import { WEBDAV_URL } from "../constants";
|
|
4
|
+
import type { OpenTUIElement } from "../opentui-jsx";
|
|
5
|
+
import type { Palette } from "../theme";
|
|
6
|
+
import type { SecretItem } from "../types";
|
|
7
|
+
import { truncate } from "../utils";
|
|
8
|
+
|
|
9
|
+
type SecretDetailProps = {
|
|
10
|
+
addName: string;
|
|
11
|
+
addSecretMode: boolean;
|
|
12
|
+
addError: string | null;
|
|
13
|
+
addingSecret: boolean;
|
|
14
|
+
confirmingDelete: boolean;
|
|
15
|
+
item: SecretItem | null;
|
|
16
|
+
onAddNameInput: (value: string) => void;
|
|
17
|
+
onAddSubmit: () => void;
|
|
18
|
+
palette: Palette;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function SecretDetail(props: SecretDetailProps): OpenTUIElement {
|
|
22
|
+
return (
|
|
23
|
+
<box
|
|
24
|
+
border
|
|
25
|
+
borderColor={props.confirmingDelete ? props.palette.danger : props.palette.border}
|
|
26
|
+
flexDirection="column"
|
|
27
|
+
flexGrow={1}
|
|
28
|
+
paddingX={2}
|
|
29
|
+
paddingY={1}
|
|
30
|
+
title={props.addSecretMode ? "Add Secret" : props.confirmingDelete ? "Confirm Delete" : "Detail"}
|
|
31
|
+
>
|
|
32
|
+
<Show when={props.addSecretMode}>
|
|
33
|
+
<AddSecretContent
|
|
34
|
+
addError={props.addError}
|
|
35
|
+
addName={props.addName}
|
|
36
|
+
addingSecret={props.addingSecret}
|
|
37
|
+
onAddNameInput={props.onAddNameInput}
|
|
38
|
+
onAddSubmit={props.onAddSubmit}
|
|
39
|
+
palette={props.palette}
|
|
40
|
+
/>
|
|
41
|
+
</Show>
|
|
42
|
+
<Show when={!props.addSecretMode}>
|
|
43
|
+
<Show
|
|
44
|
+
when={props.item !== null}
|
|
45
|
+
fallback={
|
|
46
|
+
<box flexGrow={1} alignItems="center" justifyContent="center">
|
|
47
|
+
<text fg={props.palette.muted}>
|
|
48
|
+
Select an app password to inspect
|
|
49
|
+
</text>
|
|
50
|
+
</box>
|
|
51
|
+
}
|
|
52
|
+
>
|
|
53
|
+
<SecretDetailContent
|
|
54
|
+
confirmingDelete={props.confirmingDelete}
|
|
55
|
+
item={props.item as SecretItem}
|
|
56
|
+
palette={props.palette}
|
|
57
|
+
/>
|
|
58
|
+
</Show>
|
|
59
|
+
</Show>
|
|
60
|
+
</box>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function AddSecretContent(props: {
|
|
65
|
+
addError: string | null;
|
|
66
|
+
addName: string;
|
|
67
|
+
addingSecret: boolean;
|
|
68
|
+
onAddNameInput: (value: string) => void;
|
|
69
|
+
onAddSubmit: () => void;
|
|
70
|
+
palette: Palette;
|
|
71
|
+
}): OpenTUIElement {
|
|
72
|
+
return (
|
|
73
|
+
<box flexDirection="column" gap={1}>
|
|
74
|
+
<text attributes={TextAttributes.BOLD} fg={props.palette.fg}>
|
|
75
|
+
Create App Password
|
|
76
|
+
</text>
|
|
77
|
+
<text fg={props.palette.muted}>
|
|
78
|
+
Enter a name, then press Enter to create a new app password.
|
|
79
|
+
</text>
|
|
80
|
+
<input
|
|
81
|
+
cursorColor={props.palette.cursor}
|
|
82
|
+
focused
|
|
83
|
+
focusedTextColor={props.palette.fg}
|
|
84
|
+
onInput={props.onAddNameInput}
|
|
85
|
+
onSubmit={props.onAddSubmit}
|
|
86
|
+
placeholder="NICE-APP-PASSWORD"
|
|
87
|
+
placeholderColor={props.palette.muted}
|
|
88
|
+
textColor={props.palette.fg}
|
|
89
|
+
value={props.addName}
|
|
90
|
+
/>
|
|
91
|
+
<Show when={props.addingSecret}>
|
|
92
|
+
<text fg={props.palette.muted}>Creating app password...</text>
|
|
93
|
+
</Show>
|
|
94
|
+
<Show when={props.addError}>
|
|
95
|
+
<text fg={props.palette.danger}>{props.addError}</text>
|
|
96
|
+
</Show>
|
|
97
|
+
<box flexDirection="row" flexWrap="wrap" columnGap={1}>
|
|
98
|
+
<ShortcutHint action="create secret" keyLabel="Enter" palette={props.palette} />
|
|
99
|
+
<ShortcutHint action="cancel" keyLabel="Esc" palette={props.palette} />
|
|
100
|
+
</box>
|
|
101
|
+
</box>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function SecretDetailContent(props: {
|
|
106
|
+
confirmingDelete: boolean;
|
|
107
|
+
item: SecretItem;
|
|
108
|
+
palette: Palette;
|
|
109
|
+
}): OpenTUIElement {
|
|
110
|
+
return (
|
|
111
|
+
<box flexDirection="column" gap={1}>
|
|
112
|
+
<text attributes={TextAttributes.BOLD} fg={props.palette.fg} truncate>
|
|
113
|
+
{truncate(props.item.name, 36)}
|
|
114
|
+
</text>
|
|
115
|
+
|
|
116
|
+
<box flexDirection="column">
|
|
117
|
+
<DetailRow
|
|
118
|
+
label="WebDAV"
|
|
119
|
+
maxLength={27}
|
|
120
|
+
palette={props.palette}
|
|
121
|
+
value={WEBDAV_URL}
|
|
122
|
+
shouldTruncate={false}
|
|
123
|
+
/>
|
|
124
|
+
<DetailRow
|
|
125
|
+
label="Account"
|
|
126
|
+
palette={props.palette}
|
|
127
|
+
value={props.item.account ?? "Unavailable"}
|
|
128
|
+
shouldTruncate={false}
|
|
129
|
+
/>
|
|
130
|
+
<DetailRow
|
|
131
|
+
label="Password"
|
|
132
|
+
palette={props.palette}
|
|
133
|
+
value={props.item.password}
|
|
134
|
+
/>
|
|
135
|
+
<DetailRow
|
|
136
|
+
label="Created"
|
|
137
|
+
palette={props.palette}
|
|
138
|
+
value={props.item.createdAt}
|
|
139
|
+
/>
|
|
140
|
+
</box>
|
|
141
|
+
|
|
142
|
+
<Show
|
|
143
|
+
when={props.confirmingDelete}
|
|
144
|
+
fallback={
|
|
145
|
+
<box flexDirection="row" flexWrap="wrap" columnGap={1}>
|
|
146
|
+
<ShortcutHint action="copy secret" keyLabel="Enter" palette={props.palette} />
|
|
147
|
+
<ShortcutHint action="copy URL" keyLabel="U" palette={props.palette} />
|
|
148
|
+
<ShortcutHint action="copy account" keyLabel="A" palette={props.palette} />
|
|
149
|
+
<ShortcutHint action="new secret" keyLabel="N" palette={props.palette} />
|
|
150
|
+
<ShortcutHint action="delete" keyLabel="D" palette={props.palette} />
|
|
151
|
+
</box>
|
|
152
|
+
}
|
|
153
|
+
>
|
|
154
|
+
<box border borderColor={props.palette.danger} paddingX={1}>
|
|
155
|
+
<text fg={props.palette.danger}>Press Y to delete, Esc to cancel</text>
|
|
156
|
+
</box>
|
|
157
|
+
</Show>
|
|
158
|
+
</box>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function DetailRow(props: {
|
|
163
|
+
label: string;
|
|
164
|
+
palette: Palette;
|
|
165
|
+
value: string;
|
|
166
|
+
maxLength?: number;
|
|
167
|
+
shouldTruncate?: boolean;
|
|
168
|
+
}): OpenTUIElement {
|
|
169
|
+
return (
|
|
170
|
+
<box flexDirection="row">
|
|
171
|
+
<text width={9} fg={props.palette.muted}>
|
|
172
|
+
{props.label}
|
|
173
|
+
</text>
|
|
174
|
+
<text flexGrow={1} fg={props.palette.fg} truncate={props.shouldTruncate !== false}>
|
|
175
|
+
{props.shouldTruncate !== false ? truncate(props.value, props.maxLength ?? 29) : props.value}
|
|
176
|
+
</text>
|
|
177
|
+
</box>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ShortcutHint(props: {
|
|
182
|
+
action: string;
|
|
183
|
+
keyLabel: string;
|
|
184
|
+
palette: Palette;
|
|
185
|
+
}): OpenTUIElement {
|
|
186
|
+
return (
|
|
187
|
+
<box flexDirection="row" marginRight={1}>
|
|
188
|
+
<text
|
|
189
|
+
attributes={TextAttributes.BOLD}
|
|
190
|
+
bg={props.palette.selectedBg}
|
|
191
|
+
fg={props.palette.selectedFg}
|
|
192
|
+
>
|
|
193
|
+
{" "}{props.keyLabel}{" "}
|
|
194
|
+
</text>
|
|
195
|
+
<text fg={props.palette.muted}> {props.action}</text>
|
|
196
|
+
</box>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useAtomValue } from "@effect/atom-solid";
|
|
2
|
+
import { For, Match, Switch } from "solid-js";
|
|
3
|
+
import { secretsListAtom, secretsLoadStateAtom, secretsStatusMessageAtom } from "../atom/secrets";
|
|
4
|
+
import type { OpenTUIElement } from "../opentui-jsx";
|
|
5
|
+
import type { Palette } from "../theme";
|
|
6
|
+
import { maskPassword, truncate } from "../utils";
|
|
7
|
+
|
|
8
|
+
type SecretListProps = {
|
|
9
|
+
palette: Palette;
|
|
10
|
+
selectedIndex: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function SecretList(props: SecretListProps): OpenTUIElement {
|
|
14
|
+
const items = useAtomValue(() => secretsListAtom);
|
|
15
|
+
const loadState = useAtomValue(() => secretsLoadStateAtom);
|
|
16
|
+
const statusMessage = useAtomValue(() => secretsStatusMessageAtom);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<box
|
|
20
|
+
border
|
|
21
|
+
borderColor={props.palette.border}
|
|
22
|
+
flexDirection="column"
|
|
23
|
+
flexShrink={0}
|
|
24
|
+
paddingX={1}
|
|
25
|
+
title="Secrets"
|
|
26
|
+
width={44}
|
|
27
|
+
>
|
|
28
|
+
<Switch
|
|
29
|
+
fallback={
|
|
30
|
+
<For each={items()}>
|
|
31
|
+
{(item, index) => {
|
|
32
|
+
const selected = () => index() === props.selectedIndex;
|
|
33
|
+
const prefix = () => String(index() + 1).padStart(2, "0");
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
37
|
+
<text
|
|
38
|
+
bg={selected() ? props.palette.selectedBg : undefined}
|
|
39
|
+
fg={selected() ? props.palette.selectedFg : props.palette.fg}
|
|
40
|
+
flexGrow={1}
|
|
41
|
+
truncate
|
|
42
|
+
>
|
|
43
|
+
{prefix()} {truncate(item.name.padEnd(18), 18)}{" "}
|
|
44
|
+
|
|
45
|
+
</text>
|
|
46
|
+
<text
|
|
47
|
+
truncate>
|
|
48
|
+
{maskPassword(item.password).padStart(12)}
|
|
49
|
+
</text>
|
|
50
|
+
</box>
|
|
51
|
+
|
|
52
|
+
);
|
|
53
|
+
}}
|
|
54
|
+
</For>
|
|
55
|
+
}
|
|
56
|
+
>
|
|
57
|
+
<Match when={loadState() === "loading"}>
|
|
58
|
+
<box flexGrow={1} alignItems="center" justifyContent="center">
|
|
59
|
+
<text fg={props.palette.muted}>Loading app passwords...</text>
|
|
60
|
+
</box>
|
|
61
|
+
</Match>
|
|
62
|
+
<Match when={loadState() === "error"}>
|
|
63
|
+
<box flexDirection="column" flexGrow={1} justifyContent="center" paddingX={1}>
|
|
64
|
+
<text fg={props.palette.muted}>Failed to load app passwords</text>
|
|
65
|
+
<text fg={props.palette.muted} truncate>
|
|
66
|
+
{statusMessage()}
|
|
67
|
+
</text>
|
|
68
|
+
</box>
|
|
69
|
+
</Match>
|
|
70
|
+
<Match when={loadState() === "empty"}>
|
|
71
|
+
<box flexGrow={1} alignItems="center" justifyContent="center">
|
|
72
|
+
<text fg={props.palette.muted}>No app passwords</text>
|
|
73
|
+
</box>
|
|
74
|
+
</Match>
|
|
75
|
+
</Switch>
|
|
76
|
+
</box>
|
|
77
|
+
);
|
|
78
|
+
}
|