aline-ai 0.5.12__py3-none-any.whl → 0.6.0__py3-none-any.whl

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.
@@ -1,14 +1,31 @@
1
1
  """Config Panel Widget for viewing and editing configuration."""
2
2
 
3
+ import threading
4
+ import webbrowser
3
5
  from dataclasses import fields
4
6
  from pathlib import Path
5
7
  from typing import Optional
6
8
 
7
9
  from textual.app import ComposeResult
8
- from textual.containers import Horizontal
10
+ from textual.containers import Horizontal, Vertical
9
11
  from textual.widgets import Button, DataTable, Input, Static, Switch
10
12
 
11
13
  from ..tmux_manager import _run_outer_tmux, OUTER_SESSION
14
+ from ...auth import (
15
+ load_credentials,
16
+ save_credentials,
17
+ clear_credentials,
18
+ open_login_page,
19
+ is_logged_in,
20
+ get_current_user,
21
+ find_free_port,
22
+ start_callback_server,
23
+ validate_cli_token,
24
+ )
25
+ from ...config import ReAlignConfig
26
+ from ...logging_config import setup_logger
27
+
28
+ logger = setup_logger("realign.dashboard.widgets.config_panel", "dashboard.log")
12
29
 
13
30
 
14
31
  class ConfigPanel(Static):
@@ -74,6 +91,21 @@ class ConfigPanel(Static):
74
91
  ConfigPanel .tmux-settings Switch {
75
92
  width: auto;
76
93
  }
94
+
95
+ ConfigPanel .account-section {
96
+ height: auto;
97
+ margin-top: 1;
98
+ padding: 1;
99
+ border: solid $success;
100
+ }
101
+
102
+ ConfigPanel .account-section .account-status {
103
+ margin-bottom: 1;
104
+ }
105
+
106
+ ConfigPanel .account-section Button {
107
+ margin-right: 1;
108
+ }
77
109
  """
78
110
 
79
111
  def __init__(self) -> None:
@@ -83,6 +115,8 @@ class ConfigPanel(Static):
83
115
  self._selected_key: Optional[str] = None
84
116
  self._border_resize_enabled: bool = True # Track tmux border resize state
85
117
  self._syncing_switch: bool = False # Flag to prevent recursive switch updates
118
+ self._login_in_progress: bool = False # Track login state
119
+ self._refresh_timer = None # Timer for auto-refresh
86
120
 
87
121
  def compose(self) -> ComposeResult:
88
122
  """Compose the config panel layout."""
@@ -96,6 +130,14 @@ class ConfigPanel(Static):
96
130
  yield Button("Save", id="save-btn", variant="primary")
97
131
  yield Button("Reload", id="reload-btn", variant="default")
98
132
 
133
+ # Account section
134
+ with Static(classes="account-section"):
135
+ yield Static("[bold]Account[/bold]", classes="section-title")
136
+ yield Static(id="account-status", classes="account-status")
137
+ with Horizontal(classes="button-row"):
138
+ yield Button("Login", id="login-btn", variant="primary")
139
+ yield Button("Logout", id="logout-btn", variant="warning")
140
+
99
141
  # Tmux settings section
100
142
  with Static(classes="tmux-settings"):
101
143
  yield Static("[bold]Tmux Settings[/bold]", classes="section-title")
@@ -112,9 +154,15 @@ class ConfigPanel(Static):
112
154
  # Load initial data
113
155
  self.refresh_data()
114
156
 
157
+ # Update account status display
158
+ self._update_account_status()
159
+
115
160
  # Query and set the actual tmux border resize state
116
161
  self._sync_border_resize_switch()
117
162
 
163
+ # Start timer to periodically refresh account status (every 5 seconds)
164
+ self._refresh_timer = self.set_interval(5.0, self._update_account_status)
165
+
118
166
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
119
167
  """Handle row selection in the config table."""
120
168
  table = self.query_one("#config-table", DataTable)
@@ -147,7 +195,12 @@ class ConfigPanel(Static):
147
195
  self._save_config()
148
196
  elif event.button.id == "reload-btn":
149
197
  self.refresh_data()
198
+ self._update_account_status()
150
199
  self.app.notify("Configuration reloaded", title="Config")
200
+ elif event.button.id == "login-btn":
201
+ self._handle_login()
202
+ elif event.button.id == "logout-btn":
203
+ self._handle_logout()
151
204
 
152
205
  def on_switch_changed(self, event: Switch.Changed) -> None:
153
206
  """Handle switch toggle events."""
@@ -156,6 +209,129 @@ class ConfigPanel(Static):
156
209
  if event.switch.id == "border-resize-switch":
157
210
  self._toggle_border_resize(event.value)
158
211
 
212
+ def _update_account_status(self) -> None:
213
+ """Update the account status display."""
214
+ try:
215
+ status_widget = self.query_one("#account-status", Static)
216
+ login_btn = self.query_one("#login-btn", Button)
217
+ logout_btn = self.query_one("#logout-btn", Button)
218
+ except Exception:
219
+ # Widget not ready yet
220
+ return
221
+
222
+ # Don't update if login is in progress
223
+ if self._login_in_progress:
224
+ return
225
+
226
+ credentials = get_current_user()
227
+ if credentials:
228
+ status_widget.update(
229
+ f"[green]Logged in as:[/green] [bold]{credentials.email}[/bold]"
230
+ )
231
+ login_btn.disabled = True
232
+ logout_btn.disabled = False
233
+ else:
234
+ status_widget.update("[yellow]Not logged in[/yellow]")
235
+ login_btn.disabled = False
236
+ logout_btn.disabled = True
237
+
238
+ def _handle_login(self) -> None:
239
+ """Handle login button click - start login flow in background."""
240
+ if self._login_in_progress:
241
+ self.app.notify("Login already in progress...", title="Login")
242
+ return
243
+
244
+ self._login_in_progress = True
245
+
246
+ # Update UI to show login in progress
247
+ login_btn = self.query_one("#login-btn", Button)
248
+ login_btn.disabled = True
249
+ status_widget = self.query_one("#account-status", Static)
250
+ status_widget.update("[cyan]Opening browser for login...[/cyan]")
251
+
252
+ # Start login flow in background thread
253
+ def do_login():
254
+ try:
255
+ port = find_free_port()
256
+ open_login_page(callback_port=port)
257
+
258
+ # Wait for callback (up to 5 minutes)
259
+ cli_token, error = start_callback_server(port, timeout=300)
260
+
261
+ if error:
262
+ self.app.call_from_thread(
263
+ self.app.notify, f"Login failed: {error}", title="Login", severity="error"
264
+ )
265
+ self.app.call_from_thread(self._update_account_status)
266
+ return
267
+
268
+ if not cli_token:
269
+ self.app.call_from_thread(
270
+ self.app.notify, "No token received", title="Login", severity="error"
271
+ )
272
+ self.app.call_from_thread(self._update_account_status)
273
+ return
274
+
275
+ # Validate token
276
+ credentials = validate_cli_token(cli_token)
277
+ if not credentials:
278
+ self.app.call_from_thread(
279
+ self.app.notify, "Invalid token", title="Login", severity="error"
280
+ )
281
+ self.app.call_from_thread(self._update_account_status)
282
+ return
283
+
284
+ # Save credentials
285
+ if save_credentials(credentials):
286
+ # Sync Supabase uid to local config
287
+ try:
288
+ config = ReAlignConfig.load()
289
+ old_uid = config.uid
290
+ config.uid = credentials.user_id
291
+ if not config.user_name:
292
+ config.user_name = credentials.email.split("@")[0]
293
+ config.save()
294
+ logger.info(f"Synced Supabase uid to config: {credentials.user_id[:8]}...")
295
+
296
+ # V18: Upsert user info to users table
297
+ try:
298
+ from ...db import get_database
299
+ db = get_database()
300
+ db.upsert_user(config.uid, config.user_name)
301
+ except Exception as e:
302
+ logger.debug(f"Failed to upsert user to users table: {e}")
303
+ except Exception as e:
304
+ logger.warning(f"Failed to sync uid to config: {e}")
305
+
306
+ self.app.call_from_thread(
307
+ self.app.notify, f"Logged in as {credentials.email}", title="Login"
308
+ )
309
+ else:
310
+ self.app.call_from_thread(
311
+ self.app.notify, "Failed to save credentials", title="Login", severity="error"
312
+ )
313
+
314
+ self.app.call_from_thread(self._update_account_status)
315
+
316
+ finally:
317
+ self._login_in_progress = False
318
+
319
+ thread = threading.Thread(target=do_login, daemon=True)
320
+ thread.start()
321
+
322
+ self.app.notify("Complete login in browser...", title="Login")
323
+
324
+ def _handle_logout(self) -> None:
325
+ """Handle logout button click - clear credentials."""
326
+ credentials = load_credentials()
327
+ email = credentials.email if credentials else "user"
328
+
329
+ if clear_credentials():
330
+ self._update_account_status()
331
+ self.app.notify(f"Logged out: {email}", title="Account")
332
+ else:
333
+ self.app.notify("Failed to logout", title="Account", severity="error")
334
+
159
335
  def _sync_border_resize_switch(self) -> None:
160
336
  """Query tmux state and sync the switch to match."""
161
337
  try:
@@ -232,6 +232,8 @@ class EventsTable(Container):
232
232
  table.add_column("Share", key="share", width=12)
233
233
  table.add_column("Type", key="type", width=8)
234
234
  table.add_column("Sessions", key="sessions", width=8)
235
+ table.add_column("Created By", key="created_by", width=10)
236
+ table.add_column("Shared By", key="shared_by", width=10)
235
237
  table.add_column("Event ID", key="event_id", width=12)
236
238
  table.add_column("Created", key="created", width=10)
237
239
  table.cursor_type = "row"
@@ -352,6 +354,12 @@ class EventsTable(Container):
352
354
  table.update_cell(
353
355
  eid, "sessions", self._format_cell(str(event["sessions"]), eid)
354
356
  )
357
+ table.update_cell(
358
+ eid, "created_by", self._format_cell(event.get("created_by", "-"), eid)
359
+ )
360
+ table.update_cell(
361
+ eid, "shared_by", self._format_cell(event.get("shared_by", "-"), eid)
362
+ )
355
363
  table.update_cell(
356
364
  eid, "event_id", self._format_cell(event["short_id"], eid)
357
365
  )
@@ -736,6 +744,8 @@ class EventsTable(Container):
736
744
  e.event_type,
737
745
  e.created_at,
738
746
  e.share_url,
747
+ e.created_by,
748
+ e.shared_by,
739
749
  (SELECT COUNT(*) FROM event_sessions WHERE event_id = e.id) AS session_count
740
750
  FROM events e
741
751
  ORDER BY e.created_at DESC
@@ -743,8 +753,9 @@ class EventsTable(Container):
743
753
  """,
744
754
  (int(rows_per_page), int(offset)),
745
755
  ).fetchall()
746
- has_share_url = True
756
+ has_new_columns = True
747
757
  except Exception:
758
+ # Fallback for older schema without created_by/shared_by
748
759
  rows = conn.execute(
749
760
  """
750
761
  SELECT
@@ -759,18 +770,22 @@ class EventsTable(Container):
759
770
  """,
760
771
  (int(rows_per_page), int(offset)),
761
772
  ).fetchall()
762
- has_share_url = False
773
+ has_new_columns = False
763
774
 
764
775
  for i, row in enumerate(rows):
765
776
  event_id = row[0]
766
777
  title = row[1] or "(no title)"
767
778
  event_type = row[2] or "unknown"
768
779
  created_at = row[3]
769
- if has_share_url:
780
+ if has_new_columns:
770
781
  share_url = row[4]
771
- session_count = row[5]
782
+ created_by = row[5]
783
+ shared_by = row[6]
784
+ session_count = row[7]
772
785
  else:
773
786
  share_url = None
787
+ created_by = None
788
+ shared_by = None
774
789
  session_count = row[4]
775
790
 
776
791
  # Format event type
@@ -789,6 +804,26 @@ class EventsTable(Container):
789
804
  except Exception:
790
805
  created_str = created_at
791
806
 
807
+ # Look up user names from users table
808
+ created_by_display = "-"
809
+ shared_by_display = "-"
810
+ if created_by:
811
+ try:
812
+ user_row = conn.execute(
813
+ "SELECT user_name FROM users WHERE uid = ?", (created_by,)
814
+ ).fetchone()
815
+ created_by_display = user_row[0] if user_row and user_row[0] else created_by[:8] + "..."
816
+ except Exception:
817
+ created_by_display = created_by[:8] + "..." if len(created_by) > 8 else created_by
818
+ if shared_by:
819
+ try:
820
+ user_row = conn.execute(
821
+ "SELECT user_name FROM users WHERE uid = ?", (shared_by,)
822
+ ).fetchone()
823
+ shared_by_display = user_row[0] if user_row and user_row[0] else shared_by[:8] + "..."
824
+ except Exception:
825
+ shared_by_display = shared_by[:8] + "..." if len(shared_by) > 8 else shared_by
826
+
792
827
  events.append(
793
828
  {
794
829
  "index": offset + i + 1,
@@ -799,6 +834,8 @@ class EventsTable(Container):
799
834
  "sessions": session_count,
800
835
  "share_url": share_url,
801
836
  "share_id": self._extract_share_id(share_url),
837
+ "created_by": created_by_display,
838
+ "shared_by": shared_by_display,
802
839
  "created": created_str,
803
840
  }
804
841
  )
@@ -849,7 +886,7 @@ class EventsTable(Container):
849
886
  if self.wrap_mode and event.get("share_url"):
850
887
  share_val = event.get("share_url")
851
888
 
852
- # Column order: ✓, #, Title, Share, Type, Sessions, Event ID, Created
889
+ # Column order: ✓, #, Title, Share, Type, Sessions, Created By, Shared By, Event ID, Created
853
890
  table.add_row(
854
891
  self._checkbox_cell(eid),
855
892
  self._format_cell(str(event["index"]), eid),
@@ -857,6 +894,8 @@ class EventsTable(Container):
857
894
  self._format_cell(share_val, eid),
858
895
  self._format_cell(event["type"], eid),
859
896
  self._format_cell(str(event["sessions"]), eid),
897
+ self._format_cell(event.get("created_by", "-"), eid),
898
+ self._format_cell(event.get("shared_by", "-"), eid),
860
899
  self._format_cell(event["short_id"], eid),
861
900
  self._format_cell(event["created"], eid),
862
901
  key=eid,
@@ -223,6 +223,8 @@ class SessionsTable(Container):
223
223
  table.add_column("Project", key="project", width=15)
224
224
  table.add_column("Source", key="source", width=10)
225
225
  table.add_column("Turns", key="turns", width=6)
226
+ table.add_column("Created By", key="created_by", width=10)
227
+ table.add_column("Shared By", key="shared_by", width=10)
226
228
  table.add_column("Session ID", key="session_id", width=20)
227
229
  table.add_column("Last Activity", key="last_activity", width=12)
228
230
  table.cursor_type = "row"
@@ -361,6 +363,12 @@ class SessionsTable(Container):
361
363
  table.update_cell(
362
364
  sid, "turns", self._format_cell(str(session["turns"]), sid)
363
365
  )
366
+ table.update_cell(
367
+ sid, "created_by", self._format_cell(session.get("created_by", "-"), sid)
368
+ )
369
+ table.update_cell(
370
+ sid, "shared_by", self._format_cell(session.get("shared_by", "-"), sid)
371
+ )
364
372
  table.update_cell(
365
373
  sid, "session_id", self._format_cell(session["short_id"], sid)
366
374
  )
@@ -805,21 +813,43 @@ class SessionsTable(Container):
805
813
  # Get paginated sessions
806
814
  # Use cached total_turns instead of subquery for performance
807
815
  offset = (int(page) - 1) * int(rows_per_page)
808
- rows = conn.execute(
809
- """
810
- SELECT
811
- s.id,
812
- s.session_type,
813
- s.workspace_path,
814
- s.session_title,
815
- s.last_activity_at,
816
- s.total_turns
817
- FROM sessions s
818
- ORDER BY s.last_activity_at DESC
819
- LIMIT ? OFFSET ?
820
- """,
821
- (int(rows_per_page), int(offset)),
822
- ).fetchall()
816
+ try:
817
+ rows = conn.execute(
818
+ """
819
+ SELECT
820
+ s.id,
821
+ s.session_type,
822
+ s.workspace_path,
823
+ s.session_title,
824
+ s.last_activity_at,
825
+ s.total_turns,
826
+ s.created_by,
827
+ s.shared_by
828
+ FROM sessions s
829
+ ORDER BY s.last_activity_at DESC
830
+ LIMIT ? OFFSET ?
831
+ """,
832
+ (int(rows_per_page), int(offset)),
833
+ ).fetchall()
834
+ has_new_columns = True
835
+ except Exception:
836
+ # Fallback for older schema without created_by/shared_by
837
+ rows = conn.execute(
838
+ """
839
+ SELECT
840
+ s.id,
841
+ s.session_type,
842
+ s.workspace_path,
843
+ s.session_title,
844
+ s.last_activity_at,
845
+ s.total_turns
846
+ FROM sessions s
847
+ ORDER BY s.last_activity_at DESC
848
+ LIMIT ? OFFSET ?
849
+ """,
850
+ (int(rows_per_page), int(offset)),
851
+ ).fetchall()
852
+ has_new_columns = False
823
853
 
824
854
  for i, row in enumerate(rows):
825
855
  session_id = row[0]
@@ -828,6 +858,12 @@ class SessionsTable(Container):
828
858
  title = row[3] or "(no title)"
829
859
  last_activity = row[4]
830
860
  turn_count = row[5]
861
+ if has_new_columns:
862
+ created_by = row[6]
863
+ shared_by = row[7]
864
+ else:
865
+ created_by = None
866
+ shared_by = None
831
867
 
832
868
  source_map = {
833
869
  "claude": "Claude",
@@ -846,6 +882,26 @@ class SessionsTable(Container):
846
882
  except Exception:
847
883
  activity_str = last_activity
848
884
 
885
+ # Look up user names from users table
886
+ created_by_display = "-"
887
+ shared_by_display = "-"
888
+ if created_by:
889
+ try:
890
+ user_row = conn.execute(
891
+ "SELECT user_name FROM users WHERE uid = ?", (created_by,)
892
+ ).fetchone()
893
+ created_by_display = user_row[0] if user_row and user_row[0] else created_by[:8] + "..."
894
+ except Exception:
895
+ created_by_display = created_by[:8] + "..." if len(created_by) > 8 else created_by
896
+ if shared_by:
897
+ try:
898
+ user_row = conn.execute(
899
+ "SELECT user_name FROM users WHERE uid = ?", (shared_by,)
900
+ ).fetchone()
901
+ shared_by_display = user_row[0] if user_row and user_row[0] else shared_by[:8] + "..."
902
+ except Exception:
903
+ shared_by_display = shared_by[:8] + "..." if len(shared_by) > 8 else shared_by
904
+
849
905
  sessions.append(
850
906
  {
851
907
  "index": offset + i + 1,
@@ -855,6 +911,8 @@ class SessionsTable(Container):
855
911
  "project": project,
856
912
  "turns": turn_count,
857
913
  "title": title,
914
+ "created_by": created_by_display,
915
+ "shared_by": shared_by_display,
858
916
  "last_activity": activity_str,
859
917
  }
860
918
  )
@@ -905,7 +963,7 @@ class SessionsTable(Container):
905
963
  if self.wrap_mode:
906
964
  display_id = sid
907
965
 
908
- # Column order: ✓, #, Title, Project, Source, Turns, Session ID, Last Activity
966
+ # Column order: ✓, #, Title, Project, Source, Turns, Created By, Shared By, Session ID, Last Activity
909
967
  table.add_row(
910
968
  self._checkbox_cell(sid),
911
969
  self._format_cell(str(session["index"]), sid),
@@ -913,6 +971,8 @@ class SessionsTable(Container):
913
971
  self._format_cell(session["project"], sid),
914
972
  self._format_cell(session["source"], sid),
915
973
  self._format_cell(str(session["turns"]), sid),
974
+ self._format_cell(session.get("created_by", "-"), sid),
975
+ self._format_cell(session.get("shared_by", "-"), sid),
916
976
  self._format_cell(display_id, sid),
917
977
  self._format_cell(session["last_activity"], sid),
918
978
  key=sid,
realign/db/base.py CHANGED
@@ -49,9 +49,9 @@ class SessionRecord:
49
49
  summary_status: Optional[str] = None
50
50
  summary_locked_until: Optional[datetime] = None
51
51
  summary_error: Optional[str] = None
52
- # V9: creator information
53
- creator_name: Optional[str] = None
54
- creator_id: Optional[str] = None
52
+ # V18: user identity
53
+ created_by: Optional[str] = None # Creator UID
54
+ shared_by: Optional[str] = None # Sharer UID (who imported this)
55
55
  temp_title: Optional[str] = None
56
56
  # V10: cached total turn count for session list performance
57
57
  total_turns: Optional[int] = None
@@ -78,9 +78,6 @@ class TurnRecord:
78
78
  git_commit_hash: Optional[str] = None
79
79
  # V12: temporary title stored in DB turns.temp_title
80
80
  temp_title: Optional[str] = None
81
- # V9: creator information
82
- creator_name: Optional[str] = None
83
- creator_id: Optional[str] = None
84
81
 
85
82
 
86
83
  @dataclass
@@ -102,9 +99,9 @@ class EventRecord:
102
99
  share_id: Optional[str] = None # V14: Share ID on server (for reuse)
103
100
  share_admin_token: Optional[str] = None # V14: Admin token for extending expiry
104
101
  share_expiry_at: Optional[datetime] = None # V14: Last known expiry timestamp
105
- # V9: creator information
106
- creator_name: Optional[str] = None
107
- creator_id: Optional[str] = None
102
+ # V18: user identity
103
+ created_by: Optional[str] = None # Creator UID
104
+ shared_by: Optional[str] = None # Sharer UID (who imported this)
108
105
 
109
106
 
110
107
  @dataclass
@@ -124,8 +121,8 @@ class AgentRecord:
124
121
  status: str = "active" # 'active', 'stopped'
125
122
  attention: Optional[str] = None # 'permission_request', 'stop', or None
126
123
  source: Optional[str] = None
127
- creator_name: Optional[str] = None
128
- creator_id: Optional[str] = None
124
+ # V18: user identity
125
+ created_by: Optional[str] = None # Creator UID
129
126
 
130
127
 
131
128
  @dataclass
@@ -143,6 +140,16 @@ class AgentContextRecord:
143
140
  event_ids: Optional[List[str]] = None
144
141
 
145
142
 
143
+ @dataclass
144
+ class UserRecord:
145
+ """Represents a user in the users table (V18)."""
146
+
147
+ uid: str
148
+ user_name: Optional[str] = None
149
+ created_at: Optional[datetime] = None
150
+ updated_at: Optional[datetime] = None
151
+
152
+
146
153
  class DatabaseInterface(ABC):
147
154
  """Abstract interface for ReAlign storage backend."""
148
155
 
@@ -420,3 +427,13 @@ class DatabaseInterface(ABC):
420
427
  def get_all_locks(self, include_expired: bool = False) -> List[LockRecord]:
421
428
  """Get all locks, optionally including expired ones."""
422
429
  pass
430
+
431
+ @abstractmethod
432
+ def upsert_user(self, uid: str, user_name: Optional[str] = None) -> None:
433
+ """Insert or update a user in the users table (V18)."""
434
+ pass
435
+
436
+ @abstractmethod
437
+ def get_user(self, uid: str) -> Optional[UserRecord]:
438
+ """Get a user by UID from the users table (V18)."""
439
+ pass