all repos

onasty @ cbf53ca

a one-time notes service
14 files changed, 189 insertions(+), 46 deletions(-)
feat(web): add oauth login (#213)

* refactor(api): use frontendURL instead of domain

* feat(api): return auth tokens as url fragments in oauth flow

* docs(api): oauth flow

* web: handle oauth callbacks

* web: add oauth login

* fix: set cookies on api side
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-09-18 14:12:53 +0300
Parent: 3f33118
A api/.redocly.lint-ignore.yaml
···
        
        1
        +paths/auth/oauth-provider-callback.yml:

      
        
        2
        +  operation-2xx-response:

      
        
        3
        +    - '#/get/responses'

      
        
        4
        +

      
        
        5
        +paths/auth/oauth-provider.yml:

      
        
        6
        +  operation-2xx-response:

      
        
        7
        +    - '#/get/responses'

      
M api/paths/auth/oauth-provider-callback.yml
···
        28
        28
                 type: string

      
        29
        29
         

      
        30
        30
           responses:

      
        31
        
        -    '200':

      
        32
        
        -      description: OAuth login successful

      
        33
        
        -      content:

      
        34
        
        -        application/json:

      
        
        31
        +    '302':

      
        
        32
        +      description: Redirect to frontend with tokens

      
        
        33
        +      headers:

      
        
        34
        +        Location:

      
        
        35
        +          description: Frontend URL with tokens or error as query params

      
        35
        36
                   schema:

      
        36
        
        -            $ref: '../../components/schemas/JwtTokens.yml'

      
        37
        
        -

      
        38
        
        -    # TODO: unimplemented

      
        39
        
        -    # '302':

      
        40
        
        -    #   description: Redirect to frontend with tokens (alternative flow)

      
        41
        
        -    #   headers:

      
        42
        
        -    #     Location:

      
        43
        
        -    #       description: Frontend URL with tokens as query params or hash

      
        44
        
        -    #       schema:

      
        45
        
        -    #         type: string

      
        46
        
        -    #         example: "onasty.local/api/v1/auth/success?access=...&refresh=..."

      
        
        37
        +            type: string

      
        
        38
        +            example: "onasty.local/api/v1/auth/callback?access_token=...&refresh_token=...&error=..."

      
        47
        39
         

      
        48
        40
             '400':

      
        49
        41
               $ref: '../../components/responses/ErrorResponse.yml'

      
M api/paths/auth/oauth-provider.yml
···
        13
        13
                 enum: [google, github]

      
        14
        14
         

      
        15
        15
           responses:

      
        16
        
        -    '200':

      
        17
        
        -      description: CANNOT BE RETURNED it's here just to make redocly linter not angry at this code

      
        18
        16
             '302':

      
        19
        17
               description: Redirect to OAuth provider

      
        20
        18
             '400':

      
M cmd/api/main.go
···
        149
        149
         		notesrv,

      
        150
        150
         		cfg.AppEnv,

      
        151
        151
         		cfg.AppURL,

      
        
        152
        +		cfg.FrontendURL,

      
        152
        153
         		cfg.CORSAllowedOrigins,

      
        153
        154
         		cfg.CORSMaxAge,

      
        154
        155
         		rateLimiterConfig,

      
M e2e/e2e_test.go
···
        155
        155
         		notesrv,

      
        156
        156
         		cfg.AppEnv,

      
        157
        157
         		cfg.AppURL,

      
        
        158
        +		cfg.FrontendURL,

      
        158
        159
         		cfg.CORSAllowedOrigins,

      
        159
        160
         		cfg.CORSMaxAge,

      
        160
        161
         		ratelimitCfg,

      
M internal/config/config.go
···
        22
        22
         }

      
        23
        23
         

      
        24
        24
         type Config struct {

      
        25
        
        -	AppEnv  Environment

      
        26
        
        -	AppURL  string

      
        27
        
        -	NatsURL string

      
        
        25
        +	AppEnv      Environment

      
        
        26
        +	AppURL      string

      
        
        27
        +	FrontendURL string

      
        
        28
        +	NatsURL     string

      
        28
        29
         

      
        29
        30
         	CORSAllowedOrigins []string

      
        30
        31
         	CORSMaxAge         time.Duration

      ···
        79
        80
         func NewConfig() *Config {

      
        80
        81
         	once.Do(func() {

      
        81
        82
         		instance = &Config{

      
        82
        
        -			AppEnv:  Environment(getenvOrDefault("APP_ENV", "debug")),

      
        83
        
        -			AppURL:  getenvOrDefault("APP_URL", ""),

      
        84
        
        -			NatsURL: getenvOrDefault("NATS_URL", ""),

      
        
        83
        +			AppEnv:      Environment(getenvOrDefault("APP_ENV", "debug")),

      
        
        84
        +			AppURL:      getenvOrDefault("APP_URL", ""),

      
        
        85
        +			FrontendURL: getenvOrDefault("FRONTEND_URL", ""),

      
        
        86
        +			NatsURL:     getenvOrDefault("NATS_URL", ""),

      
        85
        87
         

      
        86
        88
         			CORSAllowedOrigins: strings.Split(getenvOrDefault("CORS_ALLOWED_ORIGINS", "*"), ","),

      
        87
        89
         			CORSMaxAge:         mustParseDuration(getenvOrDefault("CORS_MAX_AGE", "12h")),

      
M internal/transport/http/apiv1/apiv1.go
···
        10
        10
         )

      
        11
        11
         

      
        12
        12
         type APIV1 struct {

      
        13
        
        -	authsrv          authsrv.AuthServicer

      
        14
        
        -	usersrv          usersrv.UserServicer

      
        15
        
        -	notesrv          notesrv.NoteServicer

      
        
        13
        +	authsrv authsrv.AuthServicer

      
        
        14
        +	usersrv usersrv.UserServicer

      
        
        15
        +	notesrv notesrv.NoteServicer

      
        
        16
        +

      
        
        17
        +	env              config.Environment

      
        16
        18
         	slowRatelimitCfg ratelimit.Config

      
        17
        
        -	env              config.Environment

      
        18
        
        -	domain           string

      
        
        19
        +

      
        
        20
        +	appURL      string

      
        
        21
        +	frontendURL string

      
        19
        22
         }

      
        20
        23
         

      
        21
        24
         func NewAPIV1(

      ···
        24
        27
         	ns notesrv.NoteServicer,

      
        25
        28
         	slowRatelimitCfg ratelimit.Config,

      
        26
        29
         	env config.Environment,

      
        27
        
        -	domain string,

      
        
        30
        +	appURL string,

      
        
        31
        +	frontendURL string,

      
        28
        32
         ) *APIV1 {

      
        29
        33
         	return &APIV1{

      
        30
        34
         		authsrv:          as,

      ···
        32
        36
         		notesrv:          ns,

      
        33
        37
         		slowRatelimitCfg: slowRatelimitCfg,

      
        34
        38
         		env:              env,

      
        35
        
        -		domain:           domain,

      
        
        39
        +		appURL:           appURL,

      
        
        40
        +		frontendURL:      frontendURL,

      
        36
        41
         	}

      
        37
        42
         }

      
        38
        43
         

      
M internal/transport/http/apiv1/auth.go
···
        2
        2
         

      
        3
        3
         import (

      
        4
        4
         	"net/http"

      
        
        5
        +	"net/url"

      
        5
        6
         	"time"

      
        6
        7
         

      
        7
        8
         	"github.com/gin-gonic/gin"

      ···
        134
        135
         		redirectInfo.State,

      
        135
        136
         		int(time.Minute.Seconds()),

      
        136
        137
         		"/",

      
        137
        
        -		a.domain,

      
        
        138
        +		a.appURL,

      
        138
        139
         		!a.env.IsDevMode(),

      
        139
        140
         		true,

      
        140
        141
         	)

      ···
        143
        144
         }

      
        144
        145
         

      
        145
        146
         func (a *APIV1) oauthCallbackHandler(c *gin.Context) {

      
        146
        
        -	state := c.Query("state")

      
        
        147
        +	redURL, err := url.Parse(a.frontendURL + "/oauth/callback")

      
        
        148
        +	if err != nil {

      
        
        149
        +		errorResponse(c, err)

      
        
        150
        +		return

      
        
        151
        +	}

      
        
        152
        +

      
        147
        153
         	storedState, err := c.Cookie(oatuhStateCookie)

      
        148
        
        -	if err != nil || state != storedState {

      
        149
        
        -		newError(c, http.StatusBadRequest, "invalid oauth state")

      
        
        154
        +	if err != nil || c.Query("state") != storedState {

      
        
        155
        +		a.oauthCallbackErrorResponse(c, redURL)

      
        150
        156
         		return

      
        151
        157
         	}

      
        152
        158
         

      ···
        156
        162
         		c.Query("code"),

      
        157
        163
         	)

      
        158
        164
         	if err != nil {

      
        159
        
        -		errorResponse(c, err)

      
        
        165
        +		a.oauthCallbackErrorResponse(c, redURL)

      
        160
        166
         		return

      
        161
        167
         	}

      
        162
        168
         

      
        163
        
        -	c.JSON(http.StatusOK, signInResponse{

      
        164
        
        -		AccessToken:  tokens.Access,

      
        165
        
        -		RefreshToken: tokens.Refresh,

      
        166
        
        -	})

      
        
        169
        +	redURL.RawQuery = url.Values{

      
        
        170
        +		"access_token":  {tokens.Access},

      
        
        171
        +		"refresh_token": {tokens.Refresh},

      
        
        172
        +	}.Encode()

      
        
        173
        +

      
        
        174
        +	c.Redirect(http.StatusFound, redURL.String())

      
        
        175
        +}

      
        
        176
        +

      
        
        177
        +func (a *APIV1) oauthCallbackErrorResponse(c *gin.Context, u *url.URL) {

      
        
        178
        +	u.RawQuery = url.Values{"error": {"internal server error"}}.Encode()

      
        
        179
        +	c.Redirect(http.StatusFound, u.String())

      
        167
        180
         }

      
M internal/transport/http/http.go
···
        19
        19
         	usersrv usersrv.UserServicer

      
        20
        20
         	notesrv notesrv.NoteServicer

      
        21
        21
         

      
        22
        
        -	env    config.Environment

      
        23
        
        -	domain string

      
        
        22
        +	env         config.Environment

      
        
        23
        +	appURL      string

      
        
        24
        +	frontendURL string

      
        24
        25
         

      
        25
        26
         	corsAllowedOrigins []string

      
        26
        27
         	corsMaxAge         time.Duration

      ···
        33
        34
         	us usersrv.UserServicer,

      
        34
        35
         	ns notesrv.NoteServicer,

      
        35
        36
         	env config.Environment,

      
        36
        
        -	domain string,

      
        
        37
        +	appURL, frontendURL string,

      
        37
        38
         	corsAllowedOrigins []string,

      
        38
        39
         	corsMaxAge time.Duration,

      
        39
        40
         	ratelimitCfg ratelimit.Config,

      ···
        44
        45
         		usersrv:            us,

      
        45
        46
         		notesrv:            ns,

      
        46
        47
         		env:                env,

      
        47
        
        -		domain:             domain,

      
        
        48
        +		appURL:             appURL,

      
        
        49
        +		frontendURL:        frontendURL,

      
        48
        50
         		corsAllowedOrigins: corsAllowedOrigins,

      
        49
        51
         		corsMaxAge:         corsMaxAge,

      
        50
        52
         		ratelimitCfg:       ratelimitCfg,

      ···
        66
        68
         	{

      
        67
        69
         		api.GET("/ping", t.pingHandler)

      
        68
        70
         		apiv1.

      
        69
        
        -			NewAPIV1(t.authsrv, t.usersrv, t.notesrv, t.slowRatelimitCfg, t.env, t.domain).

      
        
        71
        +			NewAPIV1(

      
        
        72
        +				t.authsrv,

      
        
        73
        +				t.usersrv,

      
        
        74
        +				t.notesrv,

      
        
        75
        +				t.slowRatelimitCfg,

      
        
        76
        +				t.env,

      
        
        77
        +				t.appURL,

      
        
        78
        +				t.frontendURL,

      
        
        79
        +			).

      
        70
        80
         			Routes(api.Group("/v1"))

      
        71
        81
         	}

      
        72
        82
         

      
M web/src/Components/Form.elm
···
        1
        
        -module Components.Form exposing (ButtonStyle(..), CanBeClicked, InputStyle(..), button, input, submitButton)

      
        
        1
        +module Components.Form exposing (ButtonStyle(..), CanBeClicked, InputStyle(..), button, input, oauthButton, submitButton)

      
        2
        2
         

      
        3
        3
         import Html as H exposing (Html)

      
        4
        4
         import Html.Attributes as A

      ···
        90
        90
             | Secondary CanBeClicked

      
        91
        91
             | SecondaryDisabled CanBeClicked

      
        92
        92
             | SecondaryDanger

      
        
        93
        +    | OauthButton CanBeClicked

      
        93
        94
         

      
        94
        95
         

      
        95
        96
         button : { text : String, disabled : Bool, onClick : msg, style : ButtonStyle } -> Html msg

      ···
        113
        114
                 [ H.text opts.text ]

      
        114
        115
         

      
        115
        116
         

      
        
        117
        +oauthButton : { text : String, disabled : Bool, onClick : msg, iconURL : String } -> Html msg

      
        
        118
        +oauthButton { text, disabled, onClick, iconURL } =

      
        
        119
        +    H.button

      
        
        120
        +        [ A.type_ "button"

      
        
        121
        +        , A.class (buttonStyleToClass (OauthButton (not disabled)) "mt-2")

      
        
        122
        +        , A.disabled disabled

      
        
        123
        +        , E.onClick onClick

      
        
        124
        +        ]

      
        
        125
        +        [ H.div [ A.class "flex" ]

      
        
        126
        +            [ H.img [ A.class "w-5 h-5 mr-3", A.src iconURL ] []

      
        
        127
        +            , H.text text

      
        
        128
        +            ]

      
        
        129
        +        ]

      
        
        130
        +

      
        
        131
        +

      
        116
        132
         buttonStyleToClass : ButtonStyle -> String -> String

      
        117
        133
         buttonStyleToClass style appendClasses =

      
        118
        134
             case style of

      ···
        142
        158
                         appendClasses

      
        143
        159
                         "w-full px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors mt-3 border border-gray-300 text-gray-400 cursor-not-allowed"

      
        144
        160
                         "w-full px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors mt-3 border border-gray-300 text-gray-700 hover:bg-gray-50"

      
        
        161
        +

      
        
        162
        +        OauthButton canBeClicked ->

      
        
        163
        +            getButtonClasses canBeClicked

      
        
        164
        +                appendClasses

      
        
        165
        +                "w-full flex items-center justify-center gap-3 px-4 py-2 rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 bg-white hover:bg-gray-50 border border-gray-300 text-gray-700"

      
        
        166
        +                "w-full flex items-center justify-center gap-3 px-4 py-2 rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 opacity-50 cursor-not-allowed"

      
        145
        167
         

      
        146
        168
         

      
        147
        169
         getButtonClasses : Bool -> String -> String -> String -> String

      
M web/src/Pages/Auth.elm
···
        88
        88
             | UserChangedFormVariant FormVariant

      
        89
        89
             | UserClickedSubmit

      
        90
        90
             | UserClickedResendActivationEmail

      
        
        91
        +    | UserClickedOAuth Provider

      
        91
        92
             | ApiSignInResponded (Result Api.Error Credentials)

      
        92
        93
             | ApiSignUpResponded (Result Api.Error ())

      
        93
        94
             | ApiForgotPasswordResponded (Result Api.Error ())

      
        94
        95
             | ApiSetNewPasswordResponded (Result Api.Error ())

      
        95
        96
             | ApiResendVerificationEmail (Result Api.Error ())

      
        
        97
        +

      
        
        98
        +

      
        
        99
        +type Provider

      
        
        100
        +    = Github

      
        
        101
        +    | Google

      
        96
        102
         

      
        97
        103
         

      
        98
        104
         type Field

      ···
        149
        155
                             Api.Auth.resetPassword { onResponse = ApiSetNewPasswordResponded, token = token, password = model.password }

      
        150
        156
                     )

      
        151
        157
         

      
        
        158
        +        UserClickedOAuth provider ->

      
        
        159
        +            case provider of

      
        
        160
        +                Github ->

      
        
        161
        +                    ( model, Effect.loadExternalUrl "/api/v1/oauth/github" )

      
        
        162
        +

      
        
        163
        +                Google ->

      
        
        164
        +                    ( model, Effect.loadExternalUrl "/api/v1/oauth/google" )

      
        
        165
        +

      
        152
        166
                 UserClickedResendActivationEmail ->

      
        153
        167
                     ( { model | lastClicked = model.now }

      
        154
        168
                     , Api.Auth.resendVerificationEmail

      ···
        223
        237
             , body =

      
        224
        238
                 [ H.div [ A.class "min-h-screen flex items-center justify-center bg-gray-50 p-4" ]

      
        225
        239
                     [ H.div [ A.class "w-full max-w-md bg-white rounded-lg border border-gray-200 shadow-sm" ]

      
        226
        
        -                -- TODO: add oauth buttons

      
        227
        240
                         [ viewBanner model

      
        228
        241
                         , viewBoxHeader model.formVariant

      
        229
        242
                         , H.div [ A.class "px-6 pb-6 space-y-4" ]

      
        230
        243
                             [ viewChangeVariant model.formVariant

      
        
        244
        +                    , viewOauthButtons model.isSubmittingForm

      
        231
        245
                             , H.div [ A.class "border-t border-gray-200" ] []

      
        232
        246
                             , viewForm model

      
        233
        247
                             ]

      ···
        325
        339
                     , disabled = variant == SignUp

      
        326
        340
                     , style = Components.Form.Primary (variant == SignUp)

      
        327
        341
                     , onClick = UserChangedFormVariant SignUp

      
        
        342
        +            }

      
        
        343
        +        ]

      
        
        344
        +

      
        
        345
        +

      
        
        346
        +viewOauthButtons : Bool -> Html Msg

      
        
        347
        +viewOauthButtons isSubmittingForm =

      
        
        348
        +    H.div []

      
        
        349
        +        [ Components.Form.oauthButton

      
        
        350
        +            { text = "Sign in with Google"

      
        
        351
        +            , onClick = UserClickedOAuth Google

      
        
        352
        +            , disabled = isSubmittingForm

      
        
        353
        +            , iconURL = "/static/google.svg"

      
        
        354
        +            }

      
        
        355
        +        , Components.Form.oauthButton

      
        
        356
        +            { text = "Sign in with GitHub"

      
        
        357
        +            , onClick = UserClickedOAuth Github

      
        
        358
        +            , disabled = isSubmittingForm

      
        
        359
        +            , iconURL = "/static/github.svg"

      
        328
        360
                     }

      
        329
        361
                 ]

      
        330
        362
         

      
A web/src/Pages/Oauth/Callback.elm
···
        
        1
        +module Pages.Oauth.Callback exposing (Model, Msg, page)

      
        
        2
        +

      
        
        3
        +import Components.Box

      
        
        4
        +import Components.Utils

      
        
        5
        +import Dict exposing (Dict)

      
        
        6
        +import Effect exposing (Effect)

      
        
        7
        +import Layouts

      
        
        8
        +import Page exposing (Page)

      
        
        9
        +import Route exposing (Route)

      
        
        10
        +import Shared

      
        
        11
        +import View exposing (View)

      
        
        12
        +

      
        
        13
        +

      
        
        14
        +type alias Msg =

      
        
        15
        +    {}

      
        
        16
        +

      
        
        17
        +

      
        
        18
        +page : Shared.Model -> Route () -> Page Model Msg

      
        
        19
        +page _ route =

      
        
        20
        +    Page.new

      
        
        21
        +        { init = init route.query

      
        
        22
        +        , update = \_ m -> ( m, Effect.none )

      
        
        23
        +        , subscriptions = \_ -> Sub.none

      
        
        24
        +        , view = view

      
        
        25
        +        }

      
        
        26
        +        |> Page.withLayout (\_ -> Layouts.Header {})

      
        
        27
        +

      
        
        28
        +

      
        
        29
        +type alias Model =

      
        
        30
        +    { error : String }

      
        
        31
        +

      
        
        32
        +

      
        
        33
        +init : Dict String String -> () -> ( Model, Effect Msg )

      
        
        34
        +init query () =

      
        
        35
        +    case

      
        
        36
        +        ( Dict.get "access_token" query

      
        
        37
        +        , Dict.get "refresh_token" query

      
        
        38
        +        , Dict.get "error" query

      
        
        39
        +        )

      
        
        40
        +    of

      
        
        41
        +        ( Just at, Just rt, _ ) ->

      
        
        42
        +            ( { error = "" }, Effect.signin { accessToken = at, refreshToken = rt } )

      
        
        43
        +

      
        
        44
        +        ( _, _, Just error ) ->

      
        
        45
        +            ( { error = error }, Effect.none )

      
        
        46
        +

      
        
        47
        +        _ ->

      
        
        48
        +            ( { error = "Invalid server response" }, Effect.none )

      
        
        49
        +

      
        
        50
        +

      
        
        51
        +view : Model -> View msg

      
        
        52
        +view model =

      
        
        53
        +    { title = "Oauth"

      
        
        54
        +    , body =

      
        
        55
        +        [ Components.Utils.commonContainer

      
        
        56
        +            [ Components.Box.error model.error ]

      
        
        57
        +        ]

      
        
        58
        +    }

      
A web/static/github.svg
···
        
        1
        +<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
      
A web/static/google.svg
···
        
        1
        +<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/><path d="M1 1h22v22H1z" fill="none"/></svg>