Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76ada57dd7
|
||
|
|
bb3635e517
|
||
|
|
8bc7ff026d
|
||
|
|
94fb410c59
|
@@ -8,7 +8,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql
|
- pgdata:/var/lib/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U imap"]
|
test: ["CMD-SHELL", "pg_isready -U imap -d imapcopier"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql
|
- pgdata:/var/lib/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U imap imapcopier"]
|
test: ["CMD-SHELL", "pg_isready -U imap -d imapcopier"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/vasyansk/imap-copier/internal/store"
|
"github.com/vasyansk/imap-copier/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,6 +57,36 @@ func (s *Server) handleUpdateEndpoint(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteEndpoint removes an endpoint that no task references. A referenced
|
||||||
|
// endpoint is refused with 409 rather than a foreign-key error, and the same
|
||||||
|
// status covers the race where a task is created between check and delete.
|
||||||
|
func (s *Server) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := pathID(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
used, err := s.store.CountTasksUsingEndpoint(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if used > 0 {
|
||||||
|
http.Error(w, fmt.Sprintf("endpoint is used by %d task(s) — delete them first", used), http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteEndpoint(r.Context(), id); err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
|
||||||
|
http.Error(w, "endpoint is used by a task — delete it first", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleListEndpoints(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListEndpoints(w http.ResponseWriter, r *http.Request) {
|
||||||
eps, err := s.store.ListEndpoints(r.Context())
|
eps, err := s.store.ListEndpoints(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
api.HandleFunc("GET /api/endpoints", s.handleListEndpoints)
|
api.HandleFunc("GET /api/endpoints", s.handleListEndpoints)
|
||||||
api.HandleFunc("POST /api/endpoints", s.handleCreateEndpoint)
|
api.HandleFunc("POST /api/endpoints", s.handleCreateEndpoint)
|
||||||
api.HandleFunc("PUT /api/endpoints/{id}", s.handleUpdateEndpoint)
|
api.HandleFunc("PUT /api/endpoints/{id}", s.handleUpdateEndpoint)
|
||||||
|
api.HandleFunc("DELETE /api/endpoints/{id}", s.handleDeleteEndpoint)
|
||||||
api.HandleFunc("GET /api/tasks", s.handleListTasks)
|
api.HandleFunc("GET /api/tasks", s.handleListTasks)
|
||||||
api.HandleFunc("POST /api/tasks", s.handleCreateTask)
|
api.HandleFunc("POST /api/tasks", s.handleCreateTask)
|
||||||
api.HandleFunc("GET /api/tasks/{id}", s.handleGetTask)
|
api.HandleFunc("GET /api/tasks/{id}", s.handleGetTask)
|
||||||
|
|||||||
@@ -18,6 +18,41 @@ func TestUpdateEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteEndpoint(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a.com", Port: 993, TLSMode: "ssl"})
|
||||||
|
if err := s.DeleteEndpoint(ctx, id); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
eps, _ := s.ListEndpoints(ctx)
|
||||||
|
if len(eps) != 0 {
|
||||||
|
t.Fatalf("endpoint not deleted: %d remain", len(eps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteEndpointUsedByTaskRefused(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
ep1, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "s", Host: "a", Port: 993, TLSMode: "ssl"})
|
||||||
|
ep2, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "d", Host: "b", Port: 993, TLSMode: "ssl"})
|
||||||
|
if _, err := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: ep1, DstEndpointID: ep2}); err != nil {
|
||||||
|
t.Fatalf("create task: %v", err)
|
||||||
|
}
|
||||||
|
for _, id := range []int64{ep1, ep2} {
|
||||||
|
n, err := s.CountTasksUsingEndpoint(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("count for ep %d = %d, want 1", id, n)
|
||||||
|
}
|
||||||
|
if err := s.DeleteEndpoint(ctx, id); err == nil {
|
||||||
|
t.Fatalf("delete of referenced endpoint %d succeeded, want FK violation", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteAccountCascadesJournal(t *testing.T) {
|
func TestDeleteAccountCascadesJournal(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -26,6 +26,23 @@ func (s *Store) UpdateEndpoint(ctx context.Context, e Endpoint) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountTasksUsingEndpoint reports how many tasks reference the endpoint on
|
||||||
|
// either side, so a delete can be refused with a meaningful message instead of
|
||||||
|
// surfacing a raw foreign-key violation.
|
||||||
|
func (s *Store) CountTasksUsingEndpoint(ctx context.Context, id int64) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.Pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM tasks WHERE src_endpoint_id=$1 OR dst_endpoint_id=$1`, id).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEndpoint removes an endpoint. Tasks reference endpoints without ON
|
||||||
|
// DELETE CASCADE, so Postgres rejects the delete while any task still uses it.
|
||||||
|
func (s *Store) DeleteEndpoint(ctx context.Context, id int64) error {
|
||||||
|
_, err := s.Pool.Exec(ctx, `DELETE FROM endpoints WHERE id=$1`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetEndpoint(ctx context.Context, id int64) (Endpoint, error) {
|
func (s *Store) GetEndpoint(ctx context.Context, id int64) (Endpoint, error) {
|
||||||
var e Endpoint
|
var e Endpoint
|
||||||
err := s.Pool.QueryRow(ctx,
|
err := s.Pool.QueryRow(ctx,
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export const updateEndpoint = (
|
|||||||
body: { role_label: string; host: string; port: number; tls_mode: TLSMode },
|
body: { role_label: string; host: string; port: number; tls_mode: TLSMode },
|
||||||
) => api(`/api/endpoints/${id}`, { ...jsonBody(body), method: 'PUT' })
|
) => api(`/api/endpoints/${id}`, { ...jsonBody(body), method: 'PUT' })
|
||||||
|
|
||||||
|
export const deleteEndpoint = (id: number) => api(`/api/endpoints/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
export const deleteTask = (id: number) => api(`/api/tasks/${id}`, { method: 'DELETE' })
|
export const deleteTask = (id: number) => api(`/api/tasks/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
export const deleteAccount = (taskId: number, accountId: number) =>
|
export const deleteAccount = (taskId: number, accountId: number) =>
|
||||||
|
|||||||
+27
-2
@@ -488,11 +488,23 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-toolbar .btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.map-all {
|
.map-all {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
@@ -905,11 +917,24 @@ table.tbl a.rowlink:hover {
|
|||||||
|
|
||||||
.upload-row {
|
.upload-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* One import route per column: the action on top, its sample file underneath. */
|
||||||
|
.upload-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item .link-btn {
|
||||||
|
align-self: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.file-btn {
|
.file-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -20,6 +20,21 @@ function defaultDst(src: string, dstFolders: string[], initial: Record<string, s
|
|||||||
return src
|
return src
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exchange/Kerio special folders and their mailcow counterparts. Keyed by the
|
||||||
|
// lowercased source name so casing differences between servers don't matter.
|
||||||
|
const DEFAULT_TARGETS: Record<string, string> = {
|
||||||
|
'deleted items': 'Trash',
|
||||||
|
'deleted messages': 'Trash',
|
||||||
|
'junk e-mail': 'Junk',
|
||||||
|
'junk email': 'Junk',
|
||||||
|
spam: 'Junk',
|
||||||
|
'sent items': 'Sent',
|
||||||
|
'sent messages': 'Sent',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source folders with no counterpart on mailcow — unchecked by the defaults.
|
||||||
|
const DEFAULT_EXCLUDED = new Set(['public folders'])
|
||||||
|
|
||||||
export function FolderMappingModal({
|
export function FolderMappingModal({
|
||||||
open, srcFolders, dstFolders, initialMapping, initialExcluded, accountLabel, onConfirm, onCancel,
|
open, srcFolders, dstFolders, initialMapping, initialExcluded, accountLabel, onConfirm, onCancel,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -31,16 +46,40 @@ export function FolderMappingModal({
|
|||||||
|
|
||||||
// Options per select: all destination folders, plus the source name itself
|
// Options per select: all destination folders, plus the source name itself
|
||||||
// (marked "create") when it does not already exist on the destination.
|
// (marked "create") when it does not already exist on the destination.
|
||||||
|
const valueFor = (src: string) => choice[src] ?? defaultDst(src, dstFolders, initialMapping)
|
||||||
|
|
||||||
|
// Options per select: all destination folders, plus any name not present there
|
||||||
|
// — the source folder itself and the current selection — marked "create".
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const set = new Set(dstFolders)
|
const set = new Set(dstFolders)
|
||||||
return (src: string) => {
|
return (src: string, current: string) => {
|
||||||
const opts = [...dstFolders]
|
const opts = [...dstFolders]
|
||||||
if (!set.has(src)) opts.unshift(src)
|
if (!set.has(current)) opts.unshift(current)
|
||||||
|
if (!set.has(src) && src !== current) opts.unshift(src)
|
||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
}, [dstFolders])
|
}, [dstFolders])
|
||||||
|
|
||||||
const valueFor = (src: string) => choice[src] ?? defaultDst(src, dstFolders, initialMapping)
|
// Collapse the Exchange/Kerio folder layout onto mailcow's in one click: the
|
||||||
|
// per-account mapping is otherwise repetitive work when importing many users.
|
||||||
|
function applyDefaults() {
|
||||||
|
const nextChoice = { ...choice }
|
||||||
|
const nextSynced = { ...synced }
|
||||||
|
for (const src of srcFolders) {
|
||||||
|
const key = src.trim().toLowerCase()
|
||||||
|
if (DEFAULT_EXCLUDED.has(key)) {
|
||||||
|
nextSynced[src] = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const target = DEFAULT_TARGETS[key]
|
||||||
|
if (target) {
|
||||||
|
nextChoice[src] = target
|
||||||
|
nextSynced[src] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setChoice(nextChoice)
|
||||||
|
setSynced(nextSynced)
|
||||||
|
}
|
||||||
|
|
||||||
function confirm() {
|
function confirm() {
|
||||||
const mapping: Record<string, string> = {}
|
const mapping: Record<string, string> = {}
|
||||||
@@ -70,6 +109,15 @@ export function FolderMappingModal({
|
|||||||
Route each source folder to an existing destination folder. Leaving a folder mapped to its own name
|
Route each source folder to an existing destination folder. Leaving a folder mapped to its own name
|
||||||
creates it on the destination if missing (e.g. map <code>Спам</code> → <code>Spam</code> to avoid duplicates).
|
creates it on the destination if missing (e.g. map <code>Спам</code> → <code>Spam</code> to avoid duplicates).
|
||||||
</p>
|
</p>
|
||||||
|
<div className="map-toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={applyDefaults}
|
||||||
|
title="Map Deleted Items → Trash, Junk E-mail → Junk, Sent Items → Sent and skip Public Folders"
|
||||||
|
>
|
||||||
|
By default
|
||||||
|
</button>
|
||||||
<label className="map-all">
|
<label className="map-all">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -81,6 +129,7 @@ export function FolderMappingModal({
|
|||||||
/>
|
/>
|
||||||
sync all folders
|
sync all folders
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
<div className="map-grid">
|
<div className="map-grid">
|
||||||
{srcFolders.map((src) => {
|
{srcFolders.map((src) => {
|
||||||
const on = synced[src] !== false
|
const on = synced[src] !== false
|
||||||
@@ -107,10 +156,10 @@ export function FolderMappingModal({
|
|||||||
disabled={!on}
|
disabled={!on}
|
||||||
onChange={(e) => setChoice((c) => ({ ...c, [src]: e.target.value }))}
|
onChange={(e) => setChoice((c) => ({ ...c, [src]: e.target.value }))}
|
||||||
>
|
>
|
||||||
{options(src).map((f) => (
|
{options(src, val).map((f) => (
|
||||||
<option key={f} value={f}>
|
<option key={f} value={f}>
|
||||||
{f}
|
{f}
|
||||||
{f === src && !dstFolders.includes(src) ? ' (create)' : ''}
|
{dstFolders.includes(f) ? '' : ' (create)'}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { createEndpoint, listEndpoints, updateEndpoint, type Endpoint, type TLSMode } from '../api'
|
import { createEndpoint, deleteEndpoint, listEndpoints, updateEndpoint, type Endpoint, type TLSMode } from '../api'
|
||||||
|
import { useConfirm } from '../components/ConfirmProvider'
|
||||||
|
|
||||||
const emptyForm = { role_label: '', host: '', port: '993', tls_mode: 'ssl' as TLSMode }
|
const emptyForm = { role_label: '', host: '', port: '993', tls_mode: 'ssl' as TLSMode }
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export function Endpoints() {
|
|||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
function startEdit(ep: Endpoint) {
|
function startEdit(ep: Endpoint) {
|
||||||
setEditingId(ep.id)
|
setEditingId(ep.id)
|
||||||
@@ -29,6 +31,25 @@ export function Endpoints() {
|
|||||||
|
|
||||||
useEffect(reload, [])
|
useEffect(reload, [])
|
||||||
|
|
||||||
|
async function onDelete(ep: Endpoint) {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'Delete endpoint',
|
||||||
|
message: `Delete endpoint "${ep.role_label}" (${ep.host}:${ep.port})?`,
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await deleteEndpoint(ep.id)
|
||||||
|
// The form still edits a row that no longer exists — drop back to create mode.
|
||||||
|
if (editingId === ep.id) cancelEdit()
|
||||||
|
reload()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to delete endpoint')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submit(e: FormEvent) {
|
async function submit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
@@ -159,6 +180,9 @@ export function Endpoints() {
|
|||||||
<td className="num-cell">
|
<td className="num-cell">
|
||||||
<button type="button" className="link-btn" onClick={() => startEdit(ep)} disabled={busy}>
|
<button type="button" className="link-btn" onClick={() => startEdit(ep)} disabled={busy}>
|
||||||
edit
|
edit
|
||||||
|
</button>{' '}
|
||||||
|
<button type="button" className="link-btn danger" onClick={() => onDelete(ep)} disabled={busy}>
|
||||||
|
delete
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -260,18 +260,38 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadCSV(name: string, content: string) {
|
||||||
|
const url = URL.createObjectURL(new Blob([content], { type: 'text/csv' }))
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = name
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain import: comma-separated, no header, src_login,src_pass,dst_login,dst_pass.
|
||||||
function downloadExampleCSV() {
|
function downloadExampleCSV() {
|
||||||
const sample = [
|
const sample =
|
||||||
|
[
|
||||||
'alice@source.example,SrcPass1,alice@dest.example,DstPass1',
|
'alice@source.example,SrcPass1,alice@dest.example,DstPass1',
|
||||||
'bob@source.example,SrcPass2,bob@dest.example,DstPass2',
|
'bob@source.example,SrcPass2,bob@dest.example,DstPass2',
|
||||||
'carol@source.example,SrcPass3,carol@dest.example,DstPass3',
|
'carol@source.example,SrcPass3,carol@dest.example,DstPass3',
|
||||||
].join('\n') + '\n'
|
].join('\n') + '\n'
|
||||||
const url = URL.createObjectURL(new Blob([sample], { type: 'text/csv' }))
|
downloadCSV('imap-copier-accounts-example.csv', sample)
|
||||||
const a = document.createElement('a')
|
}
|
||||||
a.href = url
|
|
||||||
a.download = 'imap-copier-accounts-example.csv'
|
// Kerio Connect export: semicolon-separated with a Name;FullName;Description;Enable
|
||||||
a.click()
|
// header. The password lives in Description; disabled rows and admin are skipped
|
||||||
URL.revokeObjectURL(url)
|
// on import, and the domain is supplied in the import dialog.
|
||||||
|
function downloadKerioExampleCSV() {
|
||||||
|
const sample =
|
||||||
|
[
|
||||||
|
'Name;FullName;Description;Enable',
|
||||||
|
'alice;Alice Smith;SrcPass1;Yes',
|
||||||
|
'bob;Bob Jones;SrcPass2;Yes',
|
||||||
|
'carol;Carol White (disabled, skipped);SrcPass3;No',
|
||||||
|
].join('\n') + '\n'
|
||||||
|
downloadCSV('kerio-users-example.csv', sample)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onFileChosen(e: ChangeEvent<HTMLInputElement>) {
|
async function onFileChosen(e: ChangeEvent<HTMLInputElement>) {
|
||||||
@@ -556,17 +576,24 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
|
|
||||||
<div className="divider-label">or bulk import</div>
|
<div className="divider-label">or bulk import</div>
|
||||||
<div className="upload-row">
|
<div className="upload-row">
|
||||||
|
<div className="upload-item">
|
||||||
<label className={`btn file-btn${busy !== null ? ' is-disabled' : ''}`}>
|
<label className={`btn file-btn${busy !== null ? ' is-disabled' : ''}`}>
|
||||||
{busy === 'import' ? 'Importing…' : 'Upload CSV'}
|
{busy === 'import' ? 'Importing…' : 'Upload CSV'}
|
||||||
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} />
|
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} />
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="btn" onClick={() => setKerioOpen(true)} disabled={busy !== null}>
|
|
||||||
Import from Kerio
|
|
||||||
</button>
|
|
||||||
<button type="button" className="link-btn" onClick={downloadExampleCSV}>
|
<button type="button" className="link-btn" onClick={downloadExampleCSV}>
|
||||||
download example.csv
|
download example.csv
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="upload-item">
|
||||||
|
<button type="button" className="btn" onClick={() => setKerioOpen(true)} disabled={busy !== null}>
|
||||||
|
Import from Kerio
|
||||||
|
</button>
|
||||||
|
<button type="button" className="link-btn" onClick={downloadKerioExampleCSV}>
|
||||||
|
download kerio example.csv
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
|
|||||||
Reference in New Issue
Block a user