meyi-vault-client-dev 1.0.1

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 ADDED
@@ -0,0 +1,157 @@
1
+ # vault-client
2
+
3
+ > Self-hosted AES-256-GCM encrypted password manager — React UI plugin for MeyiConnect
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install vault-client
9
+ ```
10
+
11
+ ## MeyiConnect integration (3 steps)
12
+
13
+ ### Step 1 — Add the route file
14
+
15
+ Copy to `frontend/src/routes/_authenticated/vault/$.tsx`:
16
+
17
+ ```tsx
18
+ import { createFileRoute } from "@tanstack/react-router";
19
+ import { VaultApp, VaultProvider } from "vault-client";
20
+ import "vault-client/style.css";
21
+ import api from "@/services/api";
22
+ import { users } from "@/services/users";
23
+
24
+ function VaultRoute() {
25
+ return (
26
+ <VaultProvider
27
+ api={api}
28
+ getUsers={() => users.getAll().then((r) => r.data ?? r)}
29
+ >
30
+ <VaultApp />
31
+ </VaultProvider>
32
+ );
33
+ }
34
+
35
+ export const Route = createFileRoute("/_authenticated/vault/$")({
36
+ component: VaultRoute,
37
+ });
38
+ ```
39
+
40
+ ### Step 2 — Add to sidebar
41
+
42
+ In `frontend/src/components/layout/data/sidebar-data.ts`:
43
+
44
+ ```ts
45
+ import { vaultNavItems } from 'vault-client'
46
+ import { FolderLock } from 'lucide-react'
47
+
48
+ // Add inside navGroups array:
49
+ {
50
+ title: 'Vault',
51
+ items: vaultNavItems.map(item => ({ ...item, icon: FolderLock })),
52
+ },
53
+ ```
54
+
55
+ ### Step 3 — Done
56
+
57
+ TanStack Router auto-discovers the new route file. Run `npm run dev` and
58
+ "Vaults" appears in the sidebar.
59
+
60
+ ---
61
+
62
+ ## Standalone usage (any React app)
63
+
64
+ ```tsx
65
+ import { VaultProvider, VaultApp } from "vault-client";
66
+ import "vault-client/style.css";
67
+ import axios from "axios";
68
+
69
+ const api = axios.create({ baseURL: "https://your-api.example.com" });
70
+
71
+ function App() {
72
+ return (
73
+ <VaultProvider
74
+ api={api}
75
+ getUsers={async () => {
76
+ const res = await api.get("/api/v1/users");
77
+ return res.data.users;
78
+ }}
79
+ >
80
+ <VaultApp />
81
+ </VaultProvider>
82
+ );
83
+ }
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Composable exports
89
+
90
+ You can use individual pieces instead of the full `VaultApp`:
91
+
92
+ ### Pages (you manage routing)
93
+
94
+ ```tsx
95
+ import { VaultPage, GroupsPage, EntriesPage } from 'vault-client'
96
+
97
+ // VaultPage — lists all vaults
98
+ <VaultPage onNavigate={(to, vaultId) => router.push(`/vault/${vaultId}`)} />
99
+
100
+ // GroupsPage — lists domain groups inside a vault
101
+ <GroupsPage vaultId={vaultId} vaultName="Production" onNavigate={...} />
102
+
103
+ // EntriesPage — lists encrypted credentials inside a group
104
+ <EntriesPage vaultId={vaultId} groupId={groupId} onNavigate={...} />
105
+ ```
106
+
107
+ ### Hooks (bring your own UI)
108
+
109
+ ```tsx
110
+ import { useVaults, useEntries, useCreateGrant } from "vault-client";
111
+
112
+ function MyCustomVaultUI() {
113
+ const { data: vaults } = useVaults();
114
+ const { data: entries } = useEntries(groupId);
115
+ const createGrant = useCreateGrant();
116
+
117
+ // build your own UI
118
+ }
119
+ ```
120
+
121
+ ### Dialogs
122
+
123
+ ```tsx
124
+ import { EntryFormDialog, GrantAccessDialog } from 'vault-client'
125
+
126
+ <EntryFormDialog
127
+ open={open}
128
+ onOpenChange={setOpen}
129
+ groupId={groupId}
130
+ />
131
+
132
+ <GrantAccessDialog
133
+ open={open}
134
+ onOpenChange={setOpen}
135
+ scope="vault"
136
+ scopeId={vaultId}
137
+ scopeLabel="Production"
138
+ />
139
+ ```
140
+
141
+ ---
142
+
143
+ ## VaultProvider props
144
+
145
+ | Prop | Type | Required | Default | Description |
146
+ | ---------- | ----------------------- | -------- | -------- | ---------------------------------------------- |
147
+ | `api` | `AxiosInstance` | ✅ | — | Host's axios instance (shares auth + base URL) |
148
+ | `getUsers` | `() => Promise<User[]>` | ✅ | — | Fetches users for grant picker |
149
+ | `basePath` | `string` | — | `/vault` | Base path for internal navigation |
150
+
151
+ ---
152
+
153
+ ## What the package does NOT include
154
+
155
+ - **Auth** — uses the host's axios instance and req.user from the server
156
+ - **Toast provider** — uses `sonner` which must be mounted by the host
157
+ - **shadcn components** — uses inline primitives; compatible with any Tailwind setup
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "meyi-vault-client-dev",
3
+ "version": "1.0.1",
4
+ "description": "Self-hosted encrypted password manager — React UI plugin for MeyiConnect",
5
+ "type": "module",
6
+ "main": "src/index.tsx",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/vault-client.js",
10
+ "require": "./dist/vault-client.cjs",
11
+ "types": "./dist/index.d.ts",
12
+ "source": "./src/index.tsx"
13
+ },
14
+ "./style.css": "./dist/style.css"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src"
19
+ ],
20
+ "scripts": {
21
+ "dev": "vite build --watch",
22
+ "build": "tsc && vite build",
23
+ "typecheck": "tsc --noEmit"
24
+ },
25
+ "keywords": [
26
+ "password-manager",
27
+ "vault",
28
+ "meyiconnect",
29
+ "plugin",
30
+ "react"
31
+ ],
32
+ "author": "Meyi Technologies",
33
+ "license": "MIT",
34
+ "peerDependencies": {
35
+ "@tanstack/react-query": "^5.x",
36
+ "@tanstack/react-router": "^1.x",
37
+ "react": "^18.x || ^19.x",
38
+ "react-dom": "^18.x || ^19.x",
39
+ "sonner": "^1.x || ^2.x"
40
+ },
41
+ "dependencies": {
42
+ "@hookform/resolvers": "^3.9.0",
43
+ "@radix-ui/react-dialog": "^1.1.15",
44
+ "@radix-ui/react-label": "^2.1.8",
45
+ "@radix-ui/react-popover": "^1.1.15",
46
+ "@radix-ui/react-scroll-area": "^1.2.10",
47
+ "@radix-ui/react-separator": "^1.1.8",
48
+ "@radix-ui/react-slot": "^1.2.4",
49
+ "@tanstack/react-table": "^8.21.3",
50
+ "axios": "^1.7.2",
51
+ "class-variance-authority": "^0.7.1",
52
+ "clsx": "^2.1.1",
53
+ "cmdk": "^1.1.1",
54
+ "date-fns": "^4.1.0",
55
+ "lucide-react": "^0.469.0",
56
+ "react-day-picker": "^9.11.1",
57
+ "react-hook-form": "^7.52.0",
58
+ "tailwind-merge": "^2.5.4",
59
+ "zod": "^3.23.8"
60
+ },
61
+ "devDependencies": {
62
+ "@types/react": "^18.3.3",
63
+ "@types/react-dom": "^18.3.0",
64
+ "@vitejs/plugin-react-swc": "^3.7.2",
65
+ "typescript": "^5.6.3",
66
+ "vite": "^6.0.5",
67
+ "vite-plugin-dts": "^4.3.0"
68
+ }
69
+ }
@@ -0,0 +1,82 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useVaults } from '@/hooks/useVault'
3
+ import { VaultPage } from '@/pages/VaultPage'
4
+ import { EntriesPage } from '@/pages/EntriesPage'
5
+ import { AuditPage } from '@/pages/AuditPage'
6
+ import { useVaultContext } from '@/context/VaultProvider'
7
+
8
+ /**
9
+ * VaultApp — the complete self-contained vault UI.
10
+ * Now uses real URL-based navigation for better UX and breadcrumb support.
11
+ */
12
+ export function VaultApp({ path: externalPath }: { path?: string } = {}) {
13
+ const { basePath } = useVaultContext()
14
+ const [path, setPath] = useState(externalPath || window.location.pathname)
15
+
16
+ // Sync with external path prop if provided (e.g. from host router)
17
+ useEffect(() => {
18
+ if (externalPath) setPath(externalPath)
19
+ }, [externalPath])
20
+
21
+ // Sync state with browser navigation
22
+ useEffect(() => {
23
+ const handlePopState = () => setPath(window.location.pathname)
24
+ window.addEventListener('popstate', handlePopState)
25
+ return () => window.removeEventListener('popstate', handlePopState)
26
+ }, [])
27
+
28
+ const navigate = (to: string) => {
29
+ // Ensure 'to' starts with a slash
30
+ const target = to.startsWith('/') ? to : `/${to}`
31
+ // Only prepend basePath if the target doesn't already start with it
32
+ const newPath = target.startsWith(basePath) ? target : `${basePath}${target}`
33
+
34
+ window.history.pushState({}, '', newPath)
35
+ setPath(newPath)
36
+ }
37
+
38
+ const { data: vaults = [] } = useVaults()
39
+
40
+ // Parse current route relative to basePath
41
+ const relativePath = path.startsWith(basePath)
42
+ ? path.slice(basePath.length).replace(/\/$/, '') || '/'
43
+ : '/'
44
+
45
+ // Route: /vault (Root)
46
+ if (relativePath === '/' || relativePath === '') {
47
+ return (
48
+ <VaultPage
49
+ onNavigate={(to, vaultId) => {
50
+ if (to === 'audit') navigate('/audit')
51
+ else if (vaultId) navigate(`/${vaultId}`)
52
+ }}
53
+ />
54
+ )
55
+ }
56
+
57
+ // Route: /audit
58
+ if (relativePath === '/audit') {
59
+ return (
60
+ <AuditPage />
61
+ )
62
+ }
63
+
64
+ // Route: /vault/:vaultId
65
+ const vaultMatch = relativePath.match(/^\/([^/]+)$/)
66
+ if (vaultMatch) {
67
+ const [, vaultId] = vaultMatch
68
+ const vault = vaults.find(v => v.id === vaultId)
69
+ return (
70
+ <EntriesPage
71
+ vaultId={vaultId}
72
+ isOwner={!!vault?.is_owner}
73
+ onNavigate={(to) => {
74
+ if (to === 'vaults') navigate('/')
75
+ }}
76
+ />
77
+ )
78
+ }
79
+
80
+ // Fallback to root
81
+ return <VaultPage onNavigate={(_to, vaultId) => navigate(`/${vaultId}`)} />
82
+ }
@@ -0,0 +1,262 @@
1
+ // components/datetime-picker.tsx
2
+ import { useEffect, useState } from 'react'
3
+ import { Calendar as CalendarIcon, Clock } from 'lucide-react'
4
+ import { format } from 'date-fns'
5
+ import { cn } from '@/lib/utils'
6
+ import { Button } from '@/components/ui/button'
7
+ import { Calendar } from '@/components/ui/calendar'
8
+ import {
9
+ Popover,
10
+ PopoverContent,
11
+ PopoverTrigger,
12
+ } from '@/components/ui/popover'
13
+ import { ScrollArea } from '@/components/ui/scroll-area'
14
+
15
+ interface DateTimePickerProps {
16
+ value?: Date
17
+ onChange: (date: Date | undefined) => void
18
+ disabled?: boolean
19
+ className?: string
20
+ placeholder?: string
21
+ minDate?: Date
22
+ }
23
+
24
+ export function DateTimePicker({
25
+ value,
26
+ onChange,
27
+ disabled,
28
+ className,
29
+ placeholder = 'Pick a date and time',
30
+ minDate,
31
+ }: DateTimePickerProps) {
32
+ const [open, setOpen] = useState(false)
33
+ const [selectedDate, setSelectedDate] = useState<Date | undefined>(value)
34
+ const [selectedTime, setSelectedTime] = useState<{
35
+ hour: string
36
+ minute: string
37
+ period: 'AM' | 'PM'
38
+ }>({
39
+ hour: '12',
40
+ minute: '00',
41
+ period: 'AM',
42
+ })
43
+
44
+ useEffect(() => {
45
+ if (value) {
46
+ setSelectedDate(value)
47
+ const hours = value.getHours()
48
+ const minutes = value.getMinutes()
49
+
50
+ setSelectedTime({
51
+ hour: hours > 12 ? String(hours - 12).padStart(2, '0') : hours === 0 ? '12' : String(hours).padStart(2, '0'),
52
+ minute: String(minutes).padStart(2, '0'),
53
+ period: hours >= 12 ? 'PM' : 'AM',
54
+ })
55
+ }
56
+ }, [value])
57
+
58
+ const hours = Array.from({ length: 12 }, (_, i) =>
59
+ String(i + 1).padStart(2, '0')
60
+ )
61
+ const minutes = Array.from({ length: 60 }, (_, i) =>
62
+ String(i).padStart(2, '0')
63
+ )
64
+
65
+ const handleDateSelect = (date: Date | undefined) => {
66
+ setSelectedDate(date)
67
+ }
68
+
69
+ const isDateDisabled = (date: Date) => {
70
+ if (!minDate) return false
71
+ const compareDate = new Date(date)
72
+ const compareMinDate = new Date(minDate)
73
+ compareDate.setHours(0, 0, 0, 0)
74
+ compareMinDate.setHours(0, 0, 0, 0)
75
+ return compareDate < compareMinDate
76
+ }
77
+
78
+ const handleTimeChange = (
79
+ type: 'hour' | 'minute' | 'period',
80
+ newValue: string
81
+ ) => {
82
+ setSelectedTime((prev) => ({
83
+ ...prev,
84
+ [type]: newValue,
85
+ }))
86
+ }
87
+
88
+ const handleApply = () => {
89
+ if (selectedDate) {
90
+ const newDate = new Date(selectedDate)
91
+ let hour = parseInt(selectedTime.hour)
92
+
93
+ if (selectedTime.period === 'PM' && hour !== 12) {
94
+ hour += 12
95
+ } else if (selectedTime.period === 'AM' && hour === 12) {
96
+ hour = 0
97
+ }
98
+
99
+ newDate.setHours(hour)
100
+ newDate.setMinutes(parseInt(selectedTime.minute))
101
+ newDate.setSeconds(0)
102
+ newDate.setMilliseconds(0)
103
+
104
+ onChange(newDate)
105
+ setOpen(false)
106
+ }
107
+ }
108
+
109
+ const handleClear = () => {
110
+ setSelectedDate(undefined)
111
+ onChange(undefined)
112
+ setSelectedTime({
113
+ hour: '12',
114
+ minute: '00',
115
+ period: 'AM',
116
+ })
117
+ setOpen(false)
118
+ }
119
+
120
+ return (
121
+ <Popover open={open} onOpenChange={setOpen}>
122
+ <PopoverTrigger asChild>
123
+ <Button
124
+ variant="outline"
125
+ disabled={disabled}
126
+ className={cn(
127
+ 'w-full justify-start text-left font-normal h-11',
128
+ !value && 'text-muted-foreground',
129
+ className
130
+ )}
131
+ >
132
+ <CalendarIcon className="mr-2 h-4 w-4" />
133
+ {value ? (
134
+ format(value, 'PPP p')
135
+ ) : (
136
+ <span>{placeholder}</span>
137
+ )}
138
+ </Button>
139
+ </PopoverTrigger>
140
+ <PopoverContent className="w-auto p-0" align="start" side="bottom" sideOffset={8}>
141
+ <div className="flex flex-col md:flex-row overflow-hidden rounded-md border shadow-xl bg-card">
142
+ {/* Calendar Section */}
143
+ <div className="border-r bg-card">
144
+ <Calendar
145
+ mode="single"
146
+ selected={selectedDate}
147
+ onSelect={handleDateSelect}
148
+ initialFocus
149
+ disabled={isDateDisabled}
150
+ />
151
+ </div>
152
+
153
+ {/* Time Section */}
154
+ <div className="flex flex-col p-4 gap-4 bg-card min-w-[240px]">
155
+ <div className="flex items-center gap-2 pb-2 border-b">
156
+ <Clock className="h-4 w-4 text-muted-foreground" />
157
+ <span className="text-sm font-medium">Select Time</span>
158
+ </div>
159
+
160
+ <div className="flex gap-3 justify-center">
161
+ {/* Hour Picker */}
162
+ <div className="flex flex-col items-center gap-2">
163
+ <label className="text-[10px] font-medium text-muted-foreground">
164
+ Hour
165
+ </label>
166
+ <ScrollArea className="h-40 w-14 rounded-md border bg-muted/5">
167
+ <div className="p-1">
168
+ {hours.map((h) => (
169
+ <button
170
+ key={h}
171
+ type="button"
172
+ onClick={() => handleTimeChange('hour', h)}
173
+ className={cn(
174
+ 'w-full rounded px-2 py-1.5 text-xs font-medium hover:bg-primary/10 transition-colors',
175
+ selectedTime.hour === h &&
176
+ 'bg-primary text-primary-foreground shadow-lg shadow-primary/20'
177
+ )}
178
+ >
179
+ {h}
180
+ </button>
181
+ ))}
182
+ </div>
183
+ </ScrollArea>
184
+ </div>
185
+
186
+ {/* Minute Picker */}
187
+ <div className="flex flex-col items-center gap-2">
188
+ <label className="text-[10px] font-medium text-muted-foreground ">
189
+ Min
190
+ </label>
191
+ <ScrollArea className="h-40 w-14 rounded-md border bg-muted/5">
192
+ <div className="p-1">
193
+ {minutes.filter(m => parseInt(m) % 5 === 0).map((m) => (
194
+ <button
195
+ key={m}
196
+ type="button"
197
+ onClick={() => handleTimeChange('minute', m)}
198
+ className={cn(
199
+ 'w-full rounded px-2 py-1.5 text-xs font-medium hover:bg-primary/10 transition-colors',
200
+ selectedTime.minute === m &&
201
+ 'bg-primary text-primary-foreground shadow-lg shadow-primary/20'
202
+ )}
203
+ >
204
+ {m}
205
+ </button>
206
+ ))}
207
+ </div>
208
+ </ScrollArea>
209
+ </div>
210
+
211
+ {/* Period Picker */}
212
+ <div className="flex flex-col items-center gap-2">
213
+ <label className="text-[10px] font-medium text-muted-foreground ">
214
+ &nbsp;
215
+ </label>
216
+ <div className="flex flex-col gap-2 pt-1">
217
+ {(['AM', 'PM'] as const).map((p) => (
218
+ <button
219
+ key={p}
220
+ type="button"
221
+ onClick={() => handleTimeChange('period', p)}
222
+ className={cn(
223
+ 'w-12 rounded px-2 py-2 text-xs font-mediumhover:bg-primary/10 transition-colors border',
224
+ selectedTime.period === p
225
+ ? 'bg-primary text-primary-foreground border-primary shadow-lg shadow-primary/20'
226
+ : 'border-transparent'
227
+ )}
228
+ >
229
+ {p}
230
+ </button>
231
+ ))}
232
+ </div>
233
+ </div>
234
+ </div>
235
+
236
+ {/* Action Buttons */}
237
+ <div className="flex gap-2 pt-4 border-t mt-auto">
238
+ <Button
239
+ type="button"
240
+ variant="outline"
241
+ size="sm"
242
+ onClick={handleClear}
243
+ className="flex-1 rounded h-9 font-semibold"
244
+ >
245
+ Clear
246
+ </Button>
247
+ <Button
248
+ type="button"
249
+ size="sm"
250
+ onClick={handleApply}
251
+ disabled={!selectedDate}
252
+ className="flex-1 rounded h-9 font-mediumbg-primary"
253
+ >
254
+ Apply
255
+ </Button>
256
+ </div>
257
+ </div>
258
+ </div>
259
+ </PopoverContent>
260
+ </Popover>
261
+ )
262
+ }