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.
|
Python bridge for Apple iCloud Reminders using pyicloud.
|
||||||
Called from Node.js via subprocess.
|
Called from Node.js via subprocess.
|
||||||
|
|
||||||
Uses CloudKit Web Services API (ckdatabasews) for modern iOS 13+ reminders,
|
Uses CloudKit Web Services API (ckdatabasews) for modern iOS 13+ reminders.
|
||||||
with fallback to legacy /rd/startup API for older accounts.
|
NO legacy CalDAV fallback — only real Reminders app data.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 icloud-reminders.py init <email> <password> [--session-dir <dir>]
|
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 lists <email> <password> [--session-dir <dir>]
|
||||||
python3 icloud-reminders.py reminders <email> <password> [--collection-guid <guid>] [--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
|
Output: JSON to stdout
|
||||||
"""
|
"""
|
||||||
@ -61,7 +62,12 @@ def ck_request(api, path, body=None):
|
|||||||
url = f"{base}{path}?{urlencode(params)}"
|
url = f"{base}{path}?{urlencode(params)}"
|
||||||
data = json.dumps(body) if body else "{}"
|
data = json.dumps(body) if body else "{}"
|
||||||
resp = api.session.post(url, data=data, headers={"Content-type": "text/plain"})
|
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):
|
def ck_list_zones(api):
|
||||||
@ -115,17 +121,17 @@ def discover_schema(api, zone_id):
|
|||||||
}
|
}
|
||||||
record_types[rt]["count"] += 1
|
record_types[rt]["count"] += 1
|
||||||
return record_types, records
|
return record_types, records
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
sys.stderr.write(f"[REMINDERS] discover_schema error: {e}\n")
|
||||||
return {}, []
|
return {}, []
|
||||||
|
|
||||||
|
|
||||||
def find_list_record_type(record_types):
|
def find_list_record_type(record_types):
|
||||||
"""Find the record type used for reminder lists."""
|
"""Find the record type used for reminder lists."""
|
||||||
# Check known names
|
|
||||||
for rt in LIST_RECORD_TYPES:
|
for rt in LIST_RECORD_TYPES:
|
||||||
if rt in record_types:
|
if rt in record_types:
|
||||||
return rt
|
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():
|
for rt, info in record_types.items():
|
||||||
fields = info.get("fields", [])
|
fields = info.get("fields", [])
|
||||||
if "title" in fields and "parentList" not in 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_id = zone.get("zoneID", {})
|
||||||
zone_name = zone_id.get("zoneName", "unknown")
|
zone_name = zone_id.get("zoneName", "unknown")
|
||||||
|
|
||||||
|
sys.stderr.write(f"[REMINDERS] Scanning zone: {zone_name}\n")
|
||||||
|
|
||||||
# Step 2: Discover schema via zone changes
|
# Step 2: Discover schema via zone changes
|
||||||
record_types, all_records = discover_schema(api, zone_id)
|
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:
|
if not record_types:
|
||||||
# Try querying common record types directly
|
# Try querying common record types directly
|
||||||
for rt in LIST_RECORD_TYPES:
|
for rt in LIST_RECORD_TYPES:
|
||||||
@ -197,11 +207,14 @@ def fetch_lists_via_cloudkit(api):
|
|||||||
"count": len(records),
|
"count": len(records),
|
||||||
"fields": list(records[0].get("fields", {}).keys()),
|
"fields": list(records[0].get("fields", {}).keys()),
|
||||||
}
|
}
|
||||||
|
sys.stderr.write(f"[REMINDERS] Found {len(records)} records of type '{rt}'\n")
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
sys.stderr.write(f"[REMINDERS] Query for '{rt}' failed: {e}\n")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
list_type = find_list_record_type(record_types)
|
list_type = find_list_record_type(record_types)
|
||||||
|
sys.stderr.write(f"[REMINDERS] List record type: {list_type}\n")
|
||||||
|
|
||||||
if list_type:
|
if list_type:
|
||||||
# Query for lists using the discovered record type
|
# Query for lists using the discovered record type
|
||||||
@ -223,13 +236,11 @@ def fetch_lists_via_cloudkit(api):
|
|||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# Fallback: extract lists from zone changes data
|
# Fallback: extract lists from zone changes data
|
||||||
# Look for any record that looks like a list
|
|
||||||
for r in all_records:
|
for r in all_records:
|
||||||
fields = r.get("fields", {})
|
fields = r.get("fields", {})
|
||||||
title = extract_field_value(fields.get("title"))
|
title = extract_field_value(fields.get("title"))
|
||||||
if title and "parentList" not in fields and "parent" not in fields:
|
if title and "parentList" not in fields and "parent" not in fields:
|
||||||
rt = r.get("recordType", "")
|
rt = r.get("recordType", "")
|
||||||
# Skip obvious task/reminder records
|
|
||||||
if rt.lower() not in ("task", "reminder", "item"):
|
if rt.lower() not in ("task", "reminder", "item"):
|
||||||
all_lists.append({
|
all_lists.append({
|
||||||
"guid": r.get("recordName", ""),
|
"guid": r.get("recordName", ""),
|
||||||
@ -343,55 +354,6 @@ def fetch_reminders_via_cloudkit(api, collection_guid=None):
|
|||||||
return [], str(e)
|
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
|
# Commands
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -458,7 +420,7 @@ def cmd_verify(args):
|
|||||||
|
|
||||||
|
|
||||||
def cmd_lists(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:
|
try:
|
||||||
api = get_api(args.email, args.password, get_session_dir(args.session_dir))
|
api = get_api(args.email, args.password, get_session_dir(args.session_dir))
|
||||||
|
|
||||||
@ -470,7 +432,7 @@ def cmd_lists(args):
|
|||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Try CloudKit first (modern iOS 13+ reminders)
|
# CloudKit only — no legacy CalDAV fallback
|
||||||
lists, ck_error = fetch_lists_via_cloudkit(api)
|
lists, ck_error = fetch_lists_via_cloudkit(api)
|
||||||
|
|
||||||
if lists:
|
if lists:
|
||||||
@ -479,16 +441,19 @@ def cmd_lists(args):
|
|||||||
"lists": lists,
|
"lists": lists,
|
||||||
"source": "cloudkit"
|
"source": "cloudkit"
|
||||||
}))
|
}))
|
||||||
return
|
elif ck_error:
|
||||||
|
sys.stderr.write(f"[REMINDERS] CloudKit error: {ck_error}\n")
|
||||||
# Fallback to legacy API
|
|
||||||
sys.stderr.write(f"[REMINDERS] CloudKit failed ({ck_error}), trying legacy API...\n")
|
|
||||||
legacy_lists = fetch_lists_via_legacy(api)
|
|
||||||
|
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"error": None,
|
"error": f"Could not fetch Reminders via iCloud (CloudKit): {ck_error}. "
|
||||||
"lists": legacy_lists,
|
"This may require Apple Developer CloudKit access or a different authentication method.",
|
||||||
"source": "legacy"
|
"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:
|
except Exception as e:
|
||||||
@ -499,7 +464,7 @@ def cmd_lists(args):
|
|||||||
|
|
||||||
|
|
||||||
def cmd_reminders(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:
|
try:
|
||||||
api = get_api(args.email, args.password, get_session_dir(args.session_dir))
|
api = get_api(args.email, args.password, get_session_dir(args.session_dir))
|
||||||
|
|
||||||
@ -510,7 +475,7 @@ def cmd_reminders(args):
|
|||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Try CloudKit first
|
# CloudKit only — no legacy CalDAV fallback
|
||||||
reminders, ck_error = fetch_reminders_via_cloudkit(api, args.collection_guid)
|
reminders, ck_error = fetch_reminders_via_cloudkit(api, args.collection_guid)
|
||||||
|
|
||||||
if reminders or ck_error is None:
|
if reminders or ck_error is None:
|
||||||
@ -519,16 +484,12 @@ def cmd_reminders(args):
|
|||||||
"reminders": reminders,
|
"reminders": reminders,
|
||||||
"source": "cloudkit"
|
"source": "cloudkit"
|
||||||
}))
|
}))
|
||||||
return
|
else:
|
||||||
|
sys.stderr.write(f"[REMINDERS] CloudKit error: {ck_error}\n")
|
||||||
# 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)
|
|
||||||
|
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"error": None,
|
"error": f"Could not fetch reminders via CloudKit: {ck_error}",
|
||||||
"reminders": legacy_reminders,
|
"reminders": [],
|
||||||
"source": "legacy"
|
"source": "cloudkit"
|
||||||
}))
|
}))
|
||||||
|
|
||||||
except Exception as e:
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description='iCloud Reminders bridge')
|
parser = argparse.ArgumentParser(description='iCloud Reminders bridge')
|
||||||
parser.add_argument('--session-dir', type=str, default=None)
|
parser.add_argument('--session-dir', type=str, default=None)
|
||||||
@ -565,6 +569,11 @@ def main():
|
|||||||
p_rem.add_argument('password')
|
p_rem.add_argument('password')
|
||||||
p_rem.add_argument('--collection-guid', type=str, default=None)
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command == 'init':
|
if args.command == 'init':
|
||||||
@ -575,6 +584,8 @@ def main():
|
|||||||
cmd_lists(args)
|
cmd_lists(args)
|
||||||
elif args.command == 'reminders':
|
elif args.command == 'reminders':
|
||||||
cmd_reminders(args)
|
cmd_reminders(args)
|
||||||
|
elif args.command == 'debug':
|
||||||
|
cmd_debug(args)
|
||||||
else:
|
else:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@ -14,15 +14,17 @@ export async function GET(request: NextRequest) {
|
|||||||
const code = searchParams.get('code');
|
const code = searchParams.get('code');
|
||||||
const state = searchParams.get('state'); // User email passed from start route
|
const state = searchParams.get('state'); // User email passed from start route
|
||||||
|
|
||||||
|
const appBaseUrl = process.env.NEXTAUTH_URL || request.url;
|
||||||
|
|
||||||
if (!code) {
|
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
|
// Get user from session or state parameter
|
||||||
const userEmail = session?.user?.email || state;
|
const userEmail = session?.user?.email || state;
|
||||||
|
|
||||||
if (!userEmail) {
|
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
|
// Find user in database
|
||||||
@ -31,7 +33,7 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
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
|
// 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
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Google OAuth error:', 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);
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
if (!session?.user?.email) {
|
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;
|
const clientId = process.env.GOOGLE_CLIENT_ID;
|
||||||
|
|||||||
@ -4651,8 +4651,7 @@ function SettingsSidebar({
|
|||||||
setTimeout(() => setAccountMsg(''), 3000);
|
setTimeout(() => setAccountMsg(''), 3000);
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to update profile:', data);
|
console.error('Failed to update profile:', data);
|
||||||
setAccountMsg(data.error || 'Failed to update profile');
|
setAccountMsg(data.details ? `${data.error}: ${data.details}` : (data.error || 'Failed to update profile'));
|
||||||
if (data.details) console.error('Update profile details:', data.details);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error updating profile:', 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