fix: Google OAuth redirect, Tasks Account creation, Apple Reminders CalDAV removal
- Fix Google OAuth callback to redirect using NEXTAUTH_URL (prevents session loss behind reverse proxy) - Create/update Account record during Google OAuth callback so Google Tasks API has access tokens - Remove legacy CalDAV fallback from Apple Reminders - only use CloudKit (iOS 13+ Reminders app) - Add debug command to icloud-reminders.py for diagnosing CloudKit connectivity - Improve profile save error messages to show details - Add better error logging throughout CloudKit reminders flow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
174c7f3e0e
commit
7f76774258
@ -3,14 +3,15 @@
|
||||
Python bridge for Apple iCloud Reminders using pyicloud.
|
||||
Called from Node.js via subprocess.
|
||||
|
||||
Uses CloudKit Web Services API (ckdatabasews) for modern iOS 13+ reminders,
|
||||
with fallback to legacy /rd/startup API for older accounts.
|
||||
Uses CloudKit Web Services API (ckdatabasews) for modern iOS 13+ reminders.
|
||||
NO legacy CalDAV fallback — only real Reminders app data.
|
||||
|
||||
Usage:
|
||||
python3 icloud-reminders.py init <email> <password> [--session-dir <dir>]
|
||||
python3 icloud-reminders.py verify <email> <code> [--session-dir <dir>]
|
||||
python3 icloud-reminders.py verify <email> <password> <code> [--session-dir <dir>]
|
||||
python3 icloud-reminders.py lists <email> <password> [--session-dir <dir>]
|
||||
python3 icloud-reminders.py reminders <email> <password> [--collection-guid <guid>] [--session-dir <dir>]
|
||||
python3 icloud-reminders.py debug <email> <password> [--session-dir <dir>]
|
||||
|
||||
Output: JSON to stdout
|
||||
"""
|
||||
@ -61,7 +62,12 @@ def ck_request(api, path, body=None):
|
||||
url = f"{base}{path}?{urlencode(params)}"
|
||||
data = json.dumps(body) if body else "{}"
|
||||
resp = api.session.post(url, data=data, headers={"Content-type": "text/plain"})
|
||||
return resp.json()
|
||||
result = resp.json()
|
||||
# Check for CloudKit errors
|
||||
if "error" in result:
|
||||
err = result["error"]
|
||||
raise Exception(f"CloudKit error: {err.get('reason', err.get('serverErrorCode', str(err)))}")
|
||||
return result
|
||||
|
||||
|
||||
def ck_list_zones(api):
|
||||
@ -115,17 +121,17 @@ def discover_schema(api, zone_id):
|
||||
}
|
||||
record_types[rt]["count"] += 1
|
||||
return record_types, records
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[REMINDERS] discover_schema error: {e}\n")
|
||||
return {}, []
|
||||
|
||||
|
||||
def find_list_record_type(record_types):
|
||||
"""Find the record type used for reminder lists."""
|
||||
# Check known names
|
||||
for rt in LIST_RECORD_TYPES:
|
||||
if rt in record_types:
|
||||
return rt
|
||||
# Heuristic: look for record types with a 'title' field
|
||||
# Heuristic: look for record types with a 'title' field but not 'parentList'
|
||||
for rt, info in record_types.items():
|
||||
fields = info.get("fields", [])
|
||||
if "title" in fields and "parentList" not in fields:
|
||||
@ -183,9 +189,13 @@ def fetch_lists_via_cloudkit(api):
|
||||
zone_id = zone.get("zoneID", {})
|
||||
zone_name = zone_id.get("zoneName", "unknown")
|
||||
|
||||
sys.stderr.write(f"[REMINDERS] Scanning zone: {zone_name}\n")
|
||||
|
||||
# Step 2: Discover schema via zone changes
|
||||
record_types, all_records = discover_schema(api, zone_id)
|
||||
|
||||
sys.stderr.write(f"[REMINDERS] Zone '{zone_name}' has record types: {list(record_types.keys())}\n")
|
||||
|
||||
if not record_types:
|
||||
# Try querying common record types directly
|
||||
for rt in LIST_RECORD_TYPES:
|
||||
@ -197,11 +207,14 @@ def fetch_lists_via_cloudkit(api):
|
||||
"count": len(records),
|
||||
"fields": list(records[0].get("fields", {}).keys()),
|
||||
}
|
||||
sys.stderr.write(f"[REMINDERS] Found {len(records)} records of type '{rt}'\n")
|
||||
break
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[REMINDERS] Query for '{rt}' failed: {e}\n")
|
||||
continue
|
||||
|
||||
list_type = find_list_record_type(record_types)
|
||||
sys.stderr.write(f"[REMINDERS] List record type: {list_type}\n")
|
||||
|
||||
if list_type:
|
||||
# Query for lists using the discovered record type
|
||||
@ -223,13 +236,11 @@ def fetch_lists_via_cloudkit(api):
|
||||
})
|
||||
else:
|
||||
# Fallback: extract lists from zone changes data
|
||||
# Look for any record that looks like a list
|
||||
for r in all_records:
|
||||
fields = r.get("fields", {})
|
||||
title = extract_field_value(fields.get("title"))
|
||||
if title and "parentList" not in fields and "parent" not in fields:
|
||||
rt = r.get("recordType", "")
|
||||
# Skip obvious task/reminder records
|
||||
if rt.lower() not in ("task", "reminder", "item"):
|
||||
all_lists.append({
|
||||
"guid": r.get("recordName", ""),
|
||||
@ -343,55 +354,6 @@ def fetch_reminders_via_cloudkit(api, collection_guid=None):
|
||||
return [], str(e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy API fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_lists_via_legacy(api):
|
||||
"""Fetch lists using the legacy /rd/startup API."""
|
||||
reminders_service = api.reminders
|
||||
reminders_service.refresh()
|
||||
|
||||
lists = []
|
||||
for list_title in reminders_service.lists.keys():
|
||||
lists.append({
|
||||
"guid": list_title,
|
||||
"title": list_title,
|
||||
})
|
||||
return lists
|
||||
|
||||
|
||||
def fetch_reminders_via_legacy(api, collection_guid=None):
|
||||
"""Fetch reminders using the legacy /rd/startup API."""
|
||||
reminders_service = api.reminders
|
||||
reminders_service.refresh()
|
||||
|
||||
result = []
|
||||
for list_title, tasks in reminders_service.lists.items():
|
||||
if collection_guid and list_title != collection_guid:
|
||||
continue
|
||||
|
||||
for i, task in enumerate(tasks):
|
||||
reminder = {
|
||||
"guid": f"{list_title}_{i}",
|
||||
"pGuid": list_title,
|
||||
"title": task.get("title", "Untitled") if isinstance(task, dict) else str(task),
|
||||
"description": (task.get("desc", "") or "") if isinstance(task, dict) else "",
|
||||
"priority": task.get("priority", 0) if isinstance(task, dict) else 0,
|
||||
"isCompleted": False,
|
||||
"dueDate": None,
|
||||
"completedDate": None,
|
||||
}
|
||||
|
||||
if isinstance(task, dict):
|
||||
due = task.get("due")
|
||||
if due and hasattr(due, 'isoformat'):
|
||||
reminder["dueDate"] = due.isoformat()
|
||||
|
||||
result.append(reminder)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -458,7 +420,7 @@ def cmd_verify(args):
|
||||
|
||||
|
||||
def cmd_lists(args):
|
||||
"""Fetch all reminder lists. Tries CloudKit first, falls back to legacy API."""
|
||||
"""Fetch all reminder lists via CloudKit only (iOS 13+ Reminders app)."""
|
||||
try:
|
||||
api = get_api(args.email, args.password, get_session_dir(args.session_dir))
|
||||
|
||||
@ -470,7 +432,7 @@ def cmd_lists(args):
|
||||
}))
|
||||
return
|
||||
|
||||
# Try CloudKit first (modern iOS 13+ reminders)
|
||||
# CloudKit only — no legacy CalDAV fallback
|
||||
lists, ck_error = fetch_lists_via_cloudkit(api)
|
||||
|
||||
if lists:
|
||||
@ -479,16 +441,19 @@ def cmd_lists(args):
|
||||
"lists": lists,
|
||||
"source": "cloudkit"
|
||||
}))
|
||||
return
|
||||
|
||||
# Fallback to legacy API
|
||||
sys.stderr.write(f"[REMINDERS] CloudKit failed ({ck_error}), trying legacy API...\n")
|
||||
legacy_lists = fetch_lists_via_legacy(api)
|
||||
|
||||
elif ck_error:
|
||||
sys.stderr.write(f"[REMINDERS] CloudKit error: {ck_error}\n")
|
||||
print(json.dumps({
|
||||
"error": None,
|
||||
"lists": legacy_lists,
|
||||
"source": "legacy"
|
||||
"error": f"Could not fetch Reminders via iCloud (CloudKit): {ck_error}. "
|
||||
"This may require Apple Developer CloudKit access or a different authentication method.",
|
||||
"lists": [],
|
||||
"source": "cloudkit"
|
||||
}))
|
||||
else:
|
||||
print(json.dumps({
|
||||
"error": "No reminder lists found via CloudKit. Your account may not have iCloud Reminders enabled.",
|
||||
"lists": [],
|
||||
"source": "cloudkit"
|
||||
}))
|
||||
|
||||
except Exception as e:
|
||||
@ -499,7 +464,7 @@ def cmd_lists(args):
|
||||
|
||||
|
||||
def cmd_reminders(args):
|
||||
"""Fetch reminders. Tries CloudKit first, falls back to legacy API."""
|
||||
"""Fetch reminders via CloudKit only (iOS 13+ Reminders app)."""
|
||||
try:
|
||||
api = get_api(args.email, args.password, get_session_dir(args.session_dir))
|
||||
|
||||
@ -510,7 +475,7 @@ def cmd_reminders(args):
|
||||
}))
|
||||
return
|
||||
|
||||
# Try CloudKit first
|
||||
# CloudKit only — no legacy CalDAV fallback
|
||||
reminders, ck_error = fetch_reminders_via_cloudkit(api, args.collection_guid)
|
||||
|
||||
if reminders or ck_error is None:
|
||||
@ -519,16 +484,12 @@ def cmd_reminders(args):
|
||||
"reminders": reminders,
|
||||
"source": "cloudkit"
|
||||
}))
|
||||
return
|
||||
|
||||
# Fallback to legacy API
|
||||
sys.stderr.write(f"[REMINDERS] CloudKit failed ({ck_error}), trying legacy API...\n")
|
||||
legacy_reminders = fetch_reminders_via_legacy(api, args.collection_guid)
|
||||
|
||||
else:
|
||||
sys.stderr.write(f"[REMINDERS] CloudKit error: {ck_error}\n")
|
||||
print(json.dumps({
|
||||
"error": None,
|
||||
"reminders": legacy_reminders,
|
||||
"source": "legacy"
|
||||
"error": f"Could not fetch reminders via CloudKit: {ck_error}",
|
||||
"reminders": [],
|
||||
"source": "cloudkit"
|
||||
}))
|
||||
|
||||
except Exception as e:
|
||||
@ -538,6 +499,49 @@ def cmd_reminders(args):
|
||||
}))
|
||||
|
||||
|
||||
def cmd_debug(args):
|
||||
"""Debug: show available webservices and CloudKit info."""
|
||||
try:
|
||||
api = get_api(args.email, args.password, get_session_dir(args.session_dir))
|
||||
|
||||
if api.requires_2fa or api.requires_2sa:
|
||||
print(json.dumps({
|
||||
"error": "2FA required",
|
||||
"services": []
|
||||
}))
|
||||
return
|
||||
|
||||
services = list(api.data.get("webservices", {}).keys())
|
||||
ck_url = api.data.get("webservices", {}).get("ckdatabasews", {}).get("url", "N/A")
|
||||
|
||||
# Try listing zones
|
||||
zones_info = []
|
||||
try:
|
||||
zones_data = ck_list_zones(api)
|
||||
for zone in zones_data.get("zones", []):
|
||||
zone_id = zone.get("zoneID", {})
|
||||
zone_name = zone_id.get("zoneName", "unknown")
|
||||
|
||||
# Try discovering record types
|
||||
record_types, records = discover_schema(api, zone_id)
|
||||
zones_info.append({
|
||||
"name": zone_name,
|
||||
"recordTypes": {k: {"count": v["count"], "fields": v["fields"]} for k, v in record_types.items()},
|
||||
"totalRecords": len(records),
|
||||
})
|
||||
except Exception as e:
|
||||
zones_info = [{"error": str(e)}]
|
||||
|
||||
print(json.dumps({
|
||||
"services": services,
|
||||
"ckdatabasews_url": ck_url,
|
||||
"zones": zones_info,
|
||||
}, indent=2))
|
||||
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='iCloud Reminders bridge')
|
||||
parser.add_argument('--session-dir', type=str, default=None)
|
||||
@ -565,6 +569,11 @@ def main():
|
||||
p_rem.add_argument('password')
|
||||
p_rem.add_argument('--collection-guid', type=str, default=None)
|
||||
|
||||
# debug
|
||||
p_debug = subparsers.add_parser('debug')
|
||||
p_debug.add_argument('email')
|
||||
p_debug.add_argument('password')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == 'init':
|
||||
@ -575,6 +584,8 @@ def main():
|
||||
cmd_lists(args)
|
||||
elif args.command == 'reminders':
|
||||
cmd_reminders(args)
|
||||
elif args.command == 'debug':
|
||||
cmd_debug(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
@ -14,15 +14,17 @@ export async function GET(request: NextRequest) {
|
||||
const code = searchParams.get('code');
|
||||
const state = searchParams.get('state'); // User email passed from start route
|
||||
|
||||
const appBaseUrl = process.env.NEXTAUTH_URL || request.url;
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.redirect(new URL('/tasks?error=oauth_code_missing', request.url));
|
||||
return NextResponse.redirect(new URL('/tasks?error=oauth_code_missing', appBaseUrl));
|
||||
}
|
||||
|
||||
// Get user from session or state parameter
|
||||
const userEmail = session?.user?.email || state;
|
||||
|
||||
if (!userEmail) {
|
||||
return NextResponse.redirect(new URL('/auth/login?error=session_expired', request.url));
|
||||
return NextResponse.redirect(new URL('/auth/login?error=session_expired', appBaseUrl));
|
||||
}
|
||||
|
||||
// Find user in database
|
||||
@ -31,7 +33,7 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.redirect(new URL('/auth/login?error=user_not_found', request.url));
|
||||
return NextResponse.redirect(new URL('/auth/login?error=user_not_found', appBaseUrl));
|
||||
}
|
||||
|
||||
// Initialize Google OAuth client
|
||||
@ -119,10 +121,43 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Also store the Google account record for Tasks API access
|
||||
const existingAccount = await prisma.account.findFirst({
|
||||
where: { userId: user.id, provider: 'google' }
|
||||
});
|
||||
|
||||
if (existingAccount) {
|
||||
await prisma.account.update({
|
||||
where: { id: existingAccount.id },
|
||||
data: {
|
||||
access_token: tokens.access_token || '',
|
||||
refresh_token: tokens.refresh_token || existingAccount.refresh_token,
|
||||
expires_at: tokens.expiry_date ? Math.floor(tokens.expiry_date / 1000) : null,
|
||||
scope: tokens.scope || existingAccount.scope,
|
||||
token_type: tokens.token_type || 'Bearer',
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await prisma.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'oauth',
|
||||
provider: 'google',
|
||||
providerAccountId: userEmail,
|
||||
access_token: tokens.access_token || '',
|
||||
refresh_token: tokens.refresh_token || null,
|
||||
expires_at: tokens.expiry_date ? Math.floor(tokens.expiry_date / 1000) : null,
|
||||
scope: tokens.scope || '',
|
||||
token_type: tokens.token_type || 'Bearer',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect to tasks page with success message
|
||||
return NextResponse.redirect(new URL('/tasks?calendar=connected', request.url));
|
||||
return NextResponse.redirect(new URL('/tasks?calendar=connected', appBaseUrl));
|
||||
} catch (error) {
|
||||
console.error('Google OAuth error:', error);
|
||||
return NextResponse.redirect(new URL('/tasks?error=oauth_failed', request.url));
|
||||
const appBaseUrl = process.env.NEXTAUTH_URL || request.url;
|
||||
return NextResponse.redirect(new URL('/tasks?error=oauth_failed', appBaseUrl));
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,8 @@ export async function GET(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.redirect(new URL('/auth/login', request.url));
|
||||
const baseUrl = process.env.NEXTAUTH_URL || request.url;
|
||||
return NextResponse.redirect(new URL('/auth/login', baseUrl));
|
||||
}
|
||||
|
||||
const clientId = process.env.GOOGLE_CLIENT_ID;
|
||||
|
||||
@ -4651,8 +4651,7 @@ function SettingsSidebar({
|
||||
setTimeout(() => setAccountMsg(''), 3000);
|
||||
} else {
|
||||
console.error('Failed to update profile:', data);
|
||||
setAccountMsg(data.error || 'Failed to update profile');
|
||||
if (data.details) console.error('Update profile details:', data.details);
|
||||
setAccountMsg(data.details ? `${data.error}: ${data.details}` : (data.error || 'Failed to update profile'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error updating profile:', e);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user