Skip to content

Commit 4d55c06

Browse files
feat(sources): allow editing CalDAV sources
Closes #72. Today the source row supports test/sync/remove and write-back-calendar selection but not editing the connection itself. Typo'd URL? Username mistake? Periodic CalDAV password rotation? The only path was delete-and-readd, which loses the synced events cache, the write-back configuration, and any per-event-type calendar selections that referenced calendars under the old source. Adds an Edit affordance on each source row (web) and a `calrs source update` command (CLI). The web POST and the CLI both go through small DB updates that scope by user_id (web) or by id-prefix (CLI matching existing convention). Notable details: - Password field is optional; empty means "keep existing". The web handler decrypts the stored blob to feed the connection-test client with credentials sync will use afterwards. The CLI uses an explicit --password flag that prompts via rpassword (so password isn't echoed and isn't visible in shell history). - URL still passes validate_caldav_url (SSRF guard); the connection test still respects the existing "Skip connection test" checkbox. - After URL or username changes, both surfaces hint that a manual sync is recommended to refresh the discovered calendar list. No automatic re-sync — the discovery flow's resolve_url() already re-resolves hrefs from the server origin, so the next manual sync self-heals. - Edit button only shown for sources the user owns; the GET handler scopes by user_id and redirects on access mismatch. Tests: 4 new web handler tests (form pre-fill, blank-password keeps existing, password rotation re-encrypts, cross-user access blocked). 583 tests total (up from 579), all green on pre-commit. Clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 80cd3ea commit 4d55c06

4 files changed

Lines changed: 453 additions & 6 deletions

File tree

src/commands/source.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@ pub enum SourceCommands {
4040
/// Source ID
4141
id: String,
4242
},
43+
/// Update a source's connection details
44+
Update {
45+
/// Source ID (or unique prefix)
46+
id: String,
47+
/// New display name
48+
#[arg(long)]
49+
name: Option<String>,
50+
/// New CalDAV URL
51+
#[arg(long)]
52+
url: Option<String>,
53+
/// New username
54+
#[arg(long)]
55+
username: Option<String>,
56+
/// Prompt for a new password (use this for scripted password rotation)
57+
#[arg(long)]
58+
password: bool,
59+
},
4360
}
4461

4562
#[derive(Tabled)]
@@ -160,6 +177,73 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: SourceCommands) -> Resu
160177
}
161178
}
162179
}
180+
SourceCommands::Update {
181+
id,
182+
name,
183+
url,
184+
username,
185+
password,
186+
} => {
187+
let existing: Option<(String, String, String, String, String)> = sqlx::query_as(
188+
"SELECT id, name, url, username, password_enc FROM caldav_sources WHERE id LIKE ? || '%'",
189+
)
190+
.bind(&id)
191+
.fetch_optional(pool)
192+
.await?;
193+
194+
let (full_id, current_name, current_url, current_username, current_password_enc) =
195+
match existing {
196+
Some(t) => t,
197+
None => {
198+
println!("{} No source found matching '{}'", "✗".red(), id);
199+
return Ok(());
200+
}
201+
};
202+
203+
let url_or_username_changed = url.is_some() || username.is_some();
204+
let new_name = name.unwrap_or(current_name);
205+
let new_url = url.unwrap_or(current_url);
206+
let new_username = username.unwrap_or(current_username);
207+
208+
if password {
209+
let new_pw = rpassword::prompt_password("New password: ").unwrap_or_default();
210+
if new_pw.is_empty() {
211+
bail!("Password is required when --password is set");
212+
}
213+
let new_enc = crate::crypto::encrypt_password(key, &new_pw)?;
214+
sqlx::query(
215+
"UPDATE caldav_sources SET name = ?, url = ?, username = ?, password_enc = ? WHERE id = ?",
216+
)
217+
.bind(&new_name)
218+
.bind(&new_url)
219+
.bind(&new_username)
220+
.bind(&new_enc)
221+
.bind(&full_id)
222+
.execute(pool)
223+
.await?;
224+
} else {
225+
let _ = current_password_enc;
226+
sqlx::query(
227+
"UPDATE caldav_sources SET name = ?, url = ?, username = ? WHERE id = ?",
228+
)
229+
.bind(&new_name)
230+
.bind(&new_url)
231+
.bind(&new_username)
232+
.bind(&full_id)
233+
.execute(pool)
234+
.await?;
235+
}
236+
237+
println!("{} Source updated: {}", "✓".green(), new_name);
238+
239+
if url_or_username_changed {
240+
println!(
241+
"{}",
242+
" URL or username changed — run `calrs sync` to refresh the calendar list."
243+
.dimmed()
244+
);
245+
}
246+
}
163247
SourceCommands::Test { id } => {
164248
let source: Option<(String, String, String, String)> = sqlx::query_as(
165249
"SELECT url, username, password_enc, name FROM caldav_sources WHERE id LIKE ? || '%'",

0 commit comments

Comments
 (0)