Skip to content

Commit 5aa129b

Browse files
feat(cli): add browser-assisted login and logout flow (#1985)
Co-authored-by: benflexcompute <ben@flexcompute.com>
1 parent cbfce1b commit 5aa129b

10 files changed

Lines changed: 1825 additions & 680 deletions

File tree

flow360/cli/api_set_func.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from click.testing import CliRunner
44

5-
from flow360 import user_config
5+
import flow360.user_config as user_config # pylint: disable=consider-using-from-import
66
from flow360.cli.app import configure
77
from flow360.log import log
88

flow360/cli/app.py

Lines changed: 105 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,25 @@
22
Commandline interface for flow360.
33
"""
44

5-
import os.path
5+
import os
66
from datetime import datetime
7-
from os.path import expanduser
87

98
import click
109
import toml
1110
from packaging.version import InvalidVersion, Version
1211

13-
from flow360.cli import dict_utils
12+
from flow360.cli.auth import LoginError, resolve_target_environment, wait_for_login
13+
from flow360.cli.auth_guidance import build_configure_command
1414
from flow360.environment import Env
15+
from flow360.user_config import (
16+
config_file,
17+
delete_apikey,
18+
read_user_config,
19+
store_apikey,
20+
write_user_config,
21+
)
1522
from flow360.version import __solver_version__, __version__
1623

17-
home = expanduser("~")
18-
# pylint: disable=invalid-name
19-
config_file = f"{home}/.flow360/config.toml"
20-
2124
if os.path.exists(config_file):
2225
with open(config_file, encoding="utf-8") as current_fh:
2326
current_config = toml.loads(current_fh.read())
@@ -37,7 +40,9 @@ def flow360():
3740
@click.option(
3841
"--apikey", prompt=False if "APIKEY_PRESENT" in globals() else "API Key", help="API Key"
3942
)
40-
@click.option("--profile", prompt=False, default="default", help="Profile, e.g., default, dev.")
43+
@click.option(
44+
"--profile", prompt=False, default="default", help="Profile, e.g., default, secondary."
45+
)
4146
@click.option(
4247
"--dev", prompt=False, type=bool, is_flag=True, help="Only use this apikey in DEV environment."
4348
)
@@ -61,46 +66,24 @@ def configure(apikey, profile, dev, uat, env, suppress_submit_warning, beta_feat
6166
Configure flow360.
6267
"""
6368
changed = False
64-
if not os.path.exists(f"{home}/.flow360"):
65-
os.makedirs(f"{home}/.flow360")
66-
67-
config = {}
68-
if os.path.exists(config_file):
69-
with open(config_file, encoding="utf-8") as file_handler:
70-
config = toml.loads(file_handler.read())
69+
config = read_user_config()
70+
_, storage_environment = resolve_target_environment(dev=dev, uat=uat, env=env)
7171

7272
if apikey is not None:
73-
if dev is True:
74-
entry = {profile: {"dev": {"apikey": apikey}}}
75-
elif uat is True:
76-
entry = {profile: {"uat": {"apikey": apikey}}}
77-
elif env:
78-
if env == "dev":
79-
raise ValueError("Cannot set dev environment with --env, please use --dev instead.")
80-
if env == "uat":
81-
raise ValueError("Cannot set uat environment with --env, please use --uat instead.")
82-
if env == "prod":
83-
raise ValueError(
84-
"Cannot set prod environment with --env, please remove --env and its argument."
85-
)
86-
entry = {profile: {env: {"apikey": apikey}}}
87-
else:
88-
entry = {profile: {"apikey": apikey}}
89-
dict_utils.merge_overwrite(config, entry)
73+
config = store_apikey(apikey, profile=profile, environment_name=storage_environment)
9074
changed = True
9175

9276
if suppress_submit_warning is not None:
93-
dict_utils.merge_overwrite(
94-
config, {"user": {"config": {"suppress_submit_warning": suppress_submit_warning}}}
95-
)
77+
config.setdefault("user", {}).setdefault("config", {})[
78+
"suppress_submit_warning"
79+
] = suppress_submit_warning
9680
changed = True
9781

9882
if beta_features is not None:
99-
dict_utils.merge_overwrite(config, {"user": {"config": {"beta_features": beta_features}}})
83+
config.setdefault("user", {}).setdefault("config", {})["beta_features"] = beta_features
10084
changed = True
10185

102-
with open(config_file, "w", encoding="utf-8") as file_handler:
103-
file_handler.write(toml.dumps(config))
86+
write_user_config(config)
10487

10588
if not changed:
10689
click.echo("Nothing to do. Your current config:")
@@ -109,6 +92,88 @@ def configure(apikey, profile, dev, uat, env, suppress_submit_warning, beta_feat
10992
click.echo("done.")
11093

11194

95+
@click.command("login", context_settings={"show_default": True})
96+
@click.option(
97+
"--profile", prompt=False, default="default", help="Profile, e.g., default, secondary."
98+
)
99+
@click.option("--dev", prompt=False, type=bool, is_flag=True, help="Log in to DEV.")
100+
@click.option("--uat", prompt=False, type=bool, is_flag=True, help="Log in to UAT.")
101+
@click.option("--env", prompt=False, default=None, help="Log in to a named environment.")
102+
@click.option(
103+
"--port",
104+
type=click.IntRange(1, 65535),
105+
default=None,
106+
help="Fixed localhost callback port. Defaults to an ephemeral port.",
107+
)
108+
@click.option(
109+
"--timeout", type=click.IntRange(1, 3600), default=120, help="Login timeout in seconds."
110+
)
111+
def login(profile, dev, uat, env, port, timeout): # pylint: disable=too-many-arguments
112+
"""
113+
Open a browser login flow and store the resulting API key.
114+
"""
115+
116+
def announce_login(details):
117+
click.echo(f"Starting local login server on {details['callback_url']}.")
118+
if details["browser_opened"] == "true":
119+
click.echo("If your browser did not open, navigate to this URL to authenticate:")
120+
else:
121+
click.echo(
122+
"Could not open your browser automatically. Navigate to this URL to authenticate:"
123+
)
124+
click.echo("")
125+
click.echo(details["login_url"])
126+
click.echo("")
127+
click.echo("Headless environment? Configure an API key manually with:")
128+
click.echo(f" {build_configure_command(details['environment'], details['profile'])}")
129+
click.echo("")
130+
131+
try:
132+
environment, _ = resolve_target_environment(dev=dev, uat=uat, env=env)
133+
result = wait_for_login(
134+
environment=environment,
135+
profile=profile,
136+
port=port,
137+
timeout=timeout,
138+
announce_login=announce_login,
139+
)
140+
except (LoginError, ValueError) as error:
141+
raise click.ClickException(str(error)) from error
142+
143+
if result.get("email"):
144+
click.echo(f"Successfully logged in as {result['email']}")
145+
else:
146+
click.echo("Successfully logged in")
147+
148+
149+
@click.command("logout", context_settings={"show_default": True})
150+
@click.option(
151+
"--profile", prompt=False, default="default", help="Profile, e.g., default, secondary."
152+
)
153+
@click.option("--dev", prompt=False, type=bool, is_flag=True, help="Remove the DEV login.")
154+
@click.option("--uat", prompt=False, type=bool, is_flag=True, help="Remove the UAT login.")
155+
@click.option("--env", prompt=False, default=None, help="Remove the login for a named environment.")
156+
def logout(profile, dev, uat, env): # pylint: disable=too-many-arguments
157+
"""
158+
Remove a stored Flow360 API key.
159+
"""
160+
try:
161+
environment, storage_environment = resolve_target_environment(dev=dev, uat=uat, env=env)
162+
except ValueError as error:
163+
raise click.ClickException(str(error)) from error
164+
165+
removed, _ = delete_apikey(profile=profile, environment_name=storage_environment)
166+
if not removed:
167+
click.echo(
168+
f"No stored API key found for profile '{profile}' in environment '{environment.name}'."
169+
)
170+
return
171+
172+
click.echo(
173+
f"Removed stored API key for profile '{profile}' in environment '{environment.name}'."
174+
)
175+
176+
112177
# For displaying all projects
113178
@click.command("show_projects", context_settings={"show_default": True})
114179
@click.option("--keyword", "-k", help="Filter projects by keyword", default=None, type=str)
@@ -250,5 +315,7 @@ def get_release_date(ver: Version) -> str:
250315

251316

252317
flow360.add_command(configure)
318+
flow360.add_command(login)
319+
flow360.add_command(logout)
253320
flow360.add_command(show_projects)
254321
flow360.add_command(version)

0 commit comments

Comments
 (0)